diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index 52ea33c0e..e22f76a44 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -29,6 +29,7 @@ from gateway.routes.projects import custom_fields as _custom_fields # noqa: F401 from gateway.routes.projects import import_clickup as _import_clickup # noqa: F401 from gateway.routes.projects import import_tasks as _import_tasks # noqa: F401 +from gateway.routes.projects import intake as _intake # noqa: F401 from gateway.routes.projects import me as _me # noqa: F401 from gateway.routes.projects import notifications as _notifications # noqa: F401 from gateway.routes.projects import personal as _personal # noqa: F401 @@ -39,6 +40,7 @@ from gateway.routes.projects import tasks as _tasks # noqa: F401 from gateway.routes.projects import tree as _tree # noqa: F401 from gateway.routes.projects import views as _views # noqa: F401 +from gateway.routes.projects import watchers as _watchers # noqa: F401 from gateway.routes.projects.core import router __all__ = ["router"] diff --git a/apps/services/gateway/gateway/routes/projects/activities.py b/apps/services/gateway/gateway/routes/projects/activities.py index af68c9396..04fa5481b 100644 --- a/apps/services/gateway/gateway/routes/projects/activities.py +++ b/apps/services/gateway/gateway/routes/projects/activities.py @@ -31,6 +31,7 @@ load_visible_task, now, record_activity, + record_field_change, resolve_visibility, router, row_to_dict, @@ -39,9 +40,11 @@ from gateway.routes.projects.notifications import ( excerpt_of, mention_targets, + new_mentions, notify, task_audience, ) +from gateway.routes.projects.watchers import ensure_watchers from pydantic import BaseModel from sqlalchemy import text @@ -128,17 +131,27 @@ async def add_comment( db, activity_type="comment", created_by=actor(user), task_id=task_id, body=body, ) + # WS-27v — commenting subscribes the commenter, BEFORE the audience is + # read so their row exists for the next event too. Harmless for this + # one: `notify` never addresses the actor (rule 1). + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) # WS-27j. Two audiences, and the order matters: a person who is BOTH - # mentioned and an assignee should get the mention, because "you were - # named" is a stronger claim on their attention than "the task you hold - # got a comment". `notifiable` dedupes within a call, so the assignee - # pass is handed only whoever the mention pass did not take. + # mentioned and a watcher/assignee should get the mention, because "you + # were named" is a stronger claim on their attention than "the task you + # follow got a comment". `notifiable` dedupes within a call, so the + # audience pass is handed only whoever the mention pass did not take. mentioned = mention_targets(body) snippet = excerpt_of(body) mention_result = await notify( db, recipients=mentioned, kind="mention", task_id=task_id, actor_id=actor(user), activity_id=str(row.id), excerpt=snippet, ) + # Being @mentioned subscribes you — but only the DELIVERED mentions: + # a person who cannot open the task must not be silently enrolled in a + # stream of its titles they could never have asked for. + await ensure_watchers( + db, task_id, mention_result["notified"], by=actor(user), + ) rest = [ who for who in await task_audience(db, task_id) if who.strip().lower() not in set(mentioned) @@ -206,8 +219,35 @@ async def edit_comment( vis = await resolve_visibility(db, user) await load_visible_task(db, vis, str(comment.task_id)) row = await update_row(db, "pm_activities", activity_id, {"body": body}) + task_id = str(comment.task_id) + # WS-27v — mention DIFFING: only the addresses this edit ADDED are + # notified. The old body is the one loaded above, before the update, so + # fixing a typo next to `@priya@…` re-pings nobody, while appending + # `@ravi@…` reaches exactly Ravi. Editing your own comment is a touch, + # so the editor (re-)subscribes too — idempotently, since they already + # subscribed when they commented. + added = new_mentions(getattr(comment, "body", None), body) + mention_result = await notify( + db, recipients=added, kind="mention", task_id=task_id, + actor_id=actor(user), activity_id=activity_id, + excerpt=excerpt_of(body), + ) + await ensure_watchers( + db, task_id, [actor(user), *mention_result["notified"]], + by=actor(user), + ) + if mention_result["notified"]: + # On the timeline, as add_comment does: why a colleague turned up + # must be readable by everyone, not only in one person's bell. + await record_activity( + db, activity_type="mention", created_by=actor(user), + task_id=task_id, + meta={"mentioned": mention_result["notified"]}, + ) await db.commit() - return row_to_dict(row, ActivityModel) + result = row_to_dict(row, ActivityModel) + result["not_notified"] = mention_result["skipped"] + return result finally: await db.close() @@ -323,20 +363,20 @@ async def revert_change( ) await update_row(db, "pm_tasks", str(task.id), restore) - await record_activity( - db, activity_type="field_change", created_by=actor(user), - task_id=str(task.id), - meta={ - "changes": [ - {"field": f, "old": c.get("new"), "new": c.get("old")} - for c in changes for f in [c.get("field")] - if f in restore or ( - custom_restored is not None - and str(f or "").startswith(_CUSTOM_PREFIX) - ) - ], - "reverted_activity_id": activity_id, - }, + # The ONE field_change door (WS-27w). `extra_meta` marks this as a + # revert, which also exempts it from description coalescing — undoing + # an edit must never be folded into the edit it undoes. + await record_field_change( + db, created_by=actor(user), task_id=str(task.id), + changes=[ + {"field": f, "old": c.get("new"), "new": c.get("old")} + for c in changes for f in [c.get("field")] + if f in restore or ( + custom_restored is not None + and str(f or "").startswith(_CUSTOM_PREFIX) + ) + ], + extra_meta={"reverted_activity_id": activity_id}, ) await db.commit() task_id = str(task.id) diff --git a/apps/services/gateway/gateway/routes/projects/automation.py b/apps/services/gateway/gateway/routes/projects/automation.py index 6120d11b1..3ffd7bf2c 100644 --- a/apps/services/gateway/gateway/routes/projects/automation.py +++ b/apps/services/gateway/gateway/routes/projects/automation.py @@ -35,16 +35,25 @@ from __future__ import annotations +from calendar import monthrange +from datetime import datetime, time from typing import Any +from zoneinfo import ZoneInfo from gateway.routes.projects.core import ( + CLOSING_CATEGORIES, + TRIAGE_CATEGORY, apply_status_transition, coerce_write_values, diff_changes, record_activity, + record_field_change, require_row, update_row, ) +from gateway.routes.projects.core import ( + now as _clock, # aliased: the sweep's own parameter is called `now` +) from sqlalchemy import text #: The fields an automation may patch. A deliberately SHORT list: everything @@ -154,9 +163,14 @@ async def apply_task_patch( diffs = diff_changes(task, after, _TRACKED) changed = [str(d["field"]) for d in diffs] if diffs: - await record_activity( - db, activity_type="field_change", created_by=actor, - task_id=task_id, meta={"changes": diffs}, + # The ONE field_change door (WS-27w): an automation's edit gets + # the same label resolution and description coalescing a human + # PATCH gets — a workflow that rewrites a description every run + # must not write a timeline row every run. Flagged automation + # (WS-27z) so the timeline renders it distinctly. + await record_field_change( + db, created_by=actor, task_id=task_id, changes=diffs, + automation=True, ) status_name: str | None = None @@ -164,7 +178,7 @@ async def apply_task_patch( target = await resolve_status(db, str(after.root_project_id), str(wanted_status)) if str(target.id) != str(after.status_id): moved = await apply_status_transition( - db, after, str(target.id), created_by=actor, + db, after, str(target.id), created_by=actor, automation=True, ) after = moved["row"] changed.append("status") @@ -191,3 +205,188 @@ def _differs(current: Any, wanted: Any) -> bool: if current is None or wanted is None: return current is not wanted return str(current) != str(wanted) + + +# ── The lifecycle sweep (WS-27z) ──────────────────────────────────────────── +# +# Policy lives in migration 166's three ROOT-project columns; the schedule +# lives in a published `/workflows` workflow (D6 — never a PM-app cron). This +# function is the whole meeting point: the workflow's sweep node calls it with +# nothing but the engine's identity, and everything it does is read from the +# database at run time. + + +def _months_before(midnight: datetime, months: int) -> datetime: + """``midnight`` moved back a whole number of calendar months. + + Calendar months, not ``months * 30`` days, because the column is named in + months and an owner who says "one month" means "the 9th of last month", + not "30 days". The day clamps to the target month's length (May 31 minus + three months is Feb 28/29), which is the same choice every calendar + arithmetic library makes. + """ + total = midnight.year * 12 + (midnight.month - 1) - months + year, month_index = divmod(total, 12) + month = month_index + 1 + day = min(midnight.day, monthrange(year, month)[1]) + return midnight.replace(year=year, month=month, day=day) + + +def _project_midnight(moment: datetime, tz_name: str) -> datetime: + """The start of ``moment``'s day in the project's timezone. + + This is what makes "a month untouched" defensible (P-28): the cutoff walks + back from the project's OWN midnight, so the boundary lands where the + people gardening the board live rather than at Greenwich. A bad zone name + (validated at write time, so this is belt-and-braces) falls back to UTC — + a sweep that skipped the project instead would silently switch the policy + off. + """ + try: + zone = ZoneInfo(str(tz_name or "UTC")) + except Exception: + zone = ZoneInfo("UTC") + return datetime.combine(moment.astimezone(zone).date(), time.min, tzinfo=zone) + + +def _closing_status(statuses: list[Any]) -> Any | None: + """The lane auto-close moves stale work to. + + The project's first ``cancelled``-category lane (by position), else its + first ``done`` one. Cancelled preferred deliberately: a task nobody has + touched for months was abandoned, not finished, and calling it done would + inflate every completion report. There is no dedicated "default closing + status" flag in the schema (``is_default`` marks where NEW tasks land), + so the category ranking IS the model — stated here, tested, and easy to + replace with a flag if one ever earns its place. + """ + for wanted in ("cancelled", "done"): + for row in statuses: # already ordered by position + if str(row.category) == wanted: + return row + return None + + +async def _sweep_candidates( + db: Any, root_id: str, status_ids: list[str], cutoff: datetime, +) -> list[Any]: + """Unarchived tasks in these lanes, untouched since before ``cutoff``. + + Membership is a POSITIVE ``ANY`` list on purpose — the caller hands in + exactly the lanes a pass may touch, so triage exemption and the archive + guard hold by construction rather than by a NOT somebody could drop. + """ + if not status_ids: + return [] + return (await db.execute( + text( + "SELECT * FROM pm_tasks " + "WHERE root_project_id = CAST(:root AS uuid) " + "AND archived_at IS NULL " + "AND status_id = ANY(CAST(:sids AS uuid[])) " + "AND updated_at < :cutoff" + ), + {"root": root_id, "sids": status_ids, "cutoff": cutoff}, + )).fetchall() + + +async def run_lifecycle_sweep( + db: Any, *, actor: str, now: datetime | None = None, +) -> dict[str, Any]: + """Apply every root project's lifecycle policy once. Idempotent. + + For each ROOT project with a policy enabled (both columns NULL — the + default — means the project is never touched): + + * **archive** — tasks whose status category is done/cancelled and whose + ``updated_at`` is older than ``archive_after_months`` calendar months + before the project's own midnight leave the default surfaces + (``archived_at`` stamped, a ``system`` activity flagged automation). + Only closed-category lanes are eligible BY CONSTRUCTION, which is + WS-27w's manual archive guard restated as a query shape. + * **close** — open tasks (not closed, not triage — WS-27u's queue is a + place work WAITS) untouched beyond ``close_after_months`` move to the + project's closing status through :func:`core.apply_status_transition`, + so ``completed_at``, the ``status_change`` activity and recurrence all + behave exactly as if a person had moved the card. + + A second run does nothing: archiving removes a task from the archive + pass's candidate set (``archived_at IS NULL``) and closing removes it + from the close pass's (its lane is now closed-category) — and the close + pass's transition bumps ``updated_at``, so even a task closed into a + project with a shorter archive window waits its full archive term. + + Writes go through the ordinary service, as ``actor`` (the workflow's + ``system:workflow:`` identity), so every change is an ordinary + timeline row wearing the automation flag. + """ + moment = now or _clock() + roots = (await db.execute( + text("SELECT * FROM pm_projects WHERE parent_project_id IS NULL"), + )).fetchall() + + swept = archived = closed = 0 + for project in roots: + archive_months = getattr(project, "archive_after_months", None) + close_months = getattr(project, "close_after_months", None) + if archive_months is None and close_months is None: + continue + swept += 1 + midnight = _project_midnight( + moment, getattr(project, "timezone", "UTC"), + ) + statuses = (await db.execute( + text( + "SELECT * FROM pm_task_statuses " + "WHERE project_id = CAST(:pid AS uuid) ORDER BY position, name" + ), + {"pid": str(project.id)}, + )).fetchall() + + if archive_months is not None: + closed_ids = [ + str(s.id) for s in statuses + if str(s.category) in CLOSING_CATEGORIES + ] + cutoff = _months_before(midnight, int(archive_months)) + for task in await _sweep_candidates( + db, str(project.id), closed_ids, cutoff, + ): + await update_row( + db, "pm_tasks", str(task.id), {"archived_at": moment}, + ) + await record_activity( + db, activity_type="system", created_by=actor, + task_id=str(task.id), + body=( + f"Task archived — untouched for " + f"{int(archive_months)} month(s) after closing" + ), + automation=True, + ) + archived += 1 + + if close_months is not None: + target = _closing_status(list(statuses)) + open_ids = [ + str(s.id) for s in statuses + if str(s.category) not in CLOSING_CATEGORIES + and str(s.category) != TRIAGE_CATEGORY + ] + if target is not None: + cutoff = _months_before(midnight, int(close_months)) + for task in await _sweep_candidates( + db, str(project.id), open_ids, cutoff, + ): + await apply_status_transition( + db, task, str(target.id), created_by=actor, + automation=True, + ) + closed += 1 + + return { + "projects": swept, + "archived": archived, + "closed": closed, + "skipped": not (archived or closed), + } diff --git a/apps/services/gateway/gateway/routes/projects/calendar.py b/apps/services/gateway/gateway/routes/projects/calendar.py index ff8cf8d58..b0ce9baec 100644 --- a/apps/services/gateway/gateway/routes/projects/calendar.py +++ b/apps/services/gateway/gateway/routes/projects/calendar.py @@ -57,6 +57,7 @@ router, row_to_dict, task_visibility_clause, + triage_exclusion_clause, ) from gateway.routes.projects.filters import ( attach_assignees, @@ -197,6 +198,9 @@ async def get_calendar( # same question, and the app's standing rule (§11.8) is that a second # endpoint per surface is how the filters start disagreeing. include_links: bool = False, + # WS-27u. The intake queue must not leak onto the month either — the ONE + # predicate is `core.triage_exclusion_clause`, applied below. + include_triage: bool = False, ) -> dict: """Every visible task whose schedule overlaps ``[from, to)``. @@ -239,6 +243,8 @@ async def get_calendar( ) clauses.extend(extra_clauses) params.update(extra_params) + if not include_triage: + clauses.append(triage_exclusion_clause()) scoped = " AND ".join(clauses) params["window_from"] = window_from diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index 843457fd3..ce7b065c6 100644 --- a/apps/services/gateway/gateway/routes/projects/core.py +++ b/apps/services/gateway/gateway/routes/projects/core.py @@ -61,11 +61,17 @@ #: `pm_task_statuses.category` — the machine-readable half of a status. Name and #: colour are the owner's; this is what completion, the personal mirror (§6.1) -#: and automation gates (§6.3) key off. +#: and automation gates (§6.3) key off. `triage` (WS-27u, migration 164) is the +#: parked-at-the-front-door value that :func:`triage_exclusion_clause` keys off. STATUS_CATEGORIES: tuple[str, ...] = ( - "backlog", "todo", "in_progress", "done", "cancelled", + "backlog", "todo", "in_progress", "done", "cancelled", "triage", ) +#: The one category the default list reads exclude (WS-27u). A constant rather +#: than a literal in the clause below so the vocabulary word and the predicate +#: cannot drift apart silently. +TRIAGE_CATEGORY = "triage" + #: Categories that close a task: crossing INTO one stamps ``completed_at``, #: crossing out clears it. ``cancelled`` counts as closed — a cancelled task is #: not outstanding work, and leaving it open would keep it in every "what is @@ -135,6 +141,11 @@ class ProjectModel(BaseModel): source: str = "manual" clickup_id: str | None = None clickup_kind: str | None = None + # WS-27z — the lifecycle policy (migration 166). ROOT rows only carry a + # meaningful value; the sweep reads the root's and the subtree inherits. + archive_after_months: int | None = None + close_after_months: int | None = None + timezone: str = "UTC" created_by: str | None = None created_at: str | None = None updated_at: str | None = None @@ -150,6 +161,10 @@ class ProjectIn(BaseModel): lead: str | None = None position: float | None = None source: str | None = None + # WS-27z — settable on ROOT projects only (the write path refuses a child). + archive_after_months: int | None = None + close_after_months: int | None = None + timezone: str | None = None class TaskModel(BaseModel): @@ -286,16 +301,43 @@ def offset(self) -> int: return (self.page - 1) * self.page_size -#: Wire sort key → the column it may order by. This dict IS the allowlist: -#: anything not a key here is a 422, never a silent fall back to the default. +#: The deterministic tail EVERY sort ends with (WS-27w item 4, P-6). Without +#: it two tasks that tie on the sort key have no total order, and a tie +#: straddling a page boundary appears on both pages — or neither — depending +#: on the plan. ``{dir}`` is the direction slot the endpoint formats in. +SORT_TIEBREAK = "t.created_at {dir}, t.id {dir}" + +#: :data:`STATUS_CATEGORIES` in lifecycle order, as a SQL array literal for the +#: semantic status sort. Built from the tuple rather than written twice, so the +#: rank can never drift from the vocabulary the CHECK mirrors. +_CATEGORY_RANK_ARRAY = "ARRAY[" + ", ".join(f"'{c}'" for c in STATUS_CATEGORIES) + "]" + +#: Wire sort key → the ORDER BY fragment it may use, with ``{dir}`` as the +#: direction slot. This dict IS the allowlist: anything not a key here is a +#: 422, never a silent fall back to the default. +#: +#: Two rules, both pinned structurally (``test_projects_hardening``): +#: +#: * ``status`` is SEMANTIC — category rank in lifecycle order, then the lane's +#: own board position, never the status NAME (P-6). Alphabetical status sort +#: puts "Backlog" before "Done" only by accident of language, and every lane +#: rename reshuffles the list. +#: * every entry ends with :data:`SORT_TIEBREAK`, so the order is total and +#: pagination never straddles a tie. TASK_SORTS: dict[str, str] = { - "created_at": "t.created_at", - "updated_at": "t.updated_at", - "due_at": "t.due_at", - "importance": "t.importance", - "title": "t.title", - "task_number": "t.task_number", - "completed_at": "t.completed_at", + "created_at": SORT_TIEBREAK, + "updated_at": f"t.updated_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "due_at": f"t.due_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "importance": f"t.importance {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "title": f"t.title {{dir}}, {SORT_TIEBREAK}", + "task_number": f"t.task_number {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "completed_at": f"t.completed_at {{dir}} NULLS LAST, {SORT_TIEBREAK}", + "status": ( + f"(SELECT array_position({_CATEGORY_RANK_ARRAY}, s.category)" + f" FROM pm_task_statuses s WHERE s.id = t.status_id) {{dir}} NULLS LAST, " + f"(SELECT s.position FROM pm_task_statuses s WHERE s.id = t.status_id)" + f" {{dir}} NULLS LAST, {SORT_TIEBREAK}" + ), } DIRECTIONS: dict[str, str] = {"asc": "ASC", "desc": "DESC"} @@ -327,6 +369,8 @@ def offset(self) -> int: # `text()` declares no column type, so an ISO string would arrive at a # timestamptz as text. "defer_until", "clarified_at", + # The intake wrapper's reappearance instant (164, WS-27u). + "snoozed_until", }) DATE_COLUMNS: frozenset[str] = frozenset({"start_date"}) @@ -754,6 +798,35 @@ def task_visibility_clause(vis: Visibility, alias: str = "t") -> str: ) +def triage_exclusion_clause(alias: str = "t") -> str: + """The default-list exclusion (WS-27u): triage-parked tasks are invisible. + + A captured task is real from birth — an ordinary ``pm_tasks`` row — but it + is PARKED: its status carries the ``triage`` category, and until a human + rules on it it must appear on **no** board, list, calendar, timeline or + search read unless the caller passed ``include_triage=true``. + + **This is the one copy of the predicate.** Every list surface appends this + helper's answer when ``include_triage`` is false, rather than writing the + clause itself — the §11.16 lesson restated for a WHERE fragment: two + hand-written copies are how one surface quietly starts leaking the queue. + The parameter-coverage test in ``test_projects_intake.py`` holds the other + half (no surface may silently drop the flag). + + Joined through the status row rather than denormalised onto tasks, for + ``build_task_filters``' reason: the category is the status's property, and + a copy on the task would need re-stamping whenever a lane is recategorised. + ``NOT EXISTS`` rather than ``NOT IN`` so a task whose status row somehow + vanished stays visible — fail open into sight, never into a task nobody + can find. + """ + return ( + f"NOT EXISTS (SELECT 1 FROM pm_task_statuses s_triage" + f" WHERE s_triage.id = {alias}.status_id" + f" AND s_triage.category = '{TRIAGE_CATEGORY}')" + ) + + # ── SQL helpers ───────────────────────────────────────────────────────────── # # Every identifier reaching an f-string below is one of ours: a literal table @@ -1045,6 +1118,7 @@ async def require_status_in_project(db: Any, root_id: str, status_id: str) -> An async def apply_status_transition( db: Any, task: Any, new_status_id: str, *, created_by: str, + automation: bool = False, ) -> dict[str, Any]: """Move a task to a new status. **Three effects, one helper.** @@ -1087,6 +1161,7 @@ async def apply_status_transition( "from_category": old_status.category, "to_category": new_status.category, }, + automation=automation, ) # WS-27o — a task crossing INTO a closing category is what advances a @@ -1120,12 +1195,20 @@ async def record_activity( project_id: str | None = None, body: str | None = None, meta: dict[str, Any] | None = None, + automation: bool = False, ) -> Any: """Write one timeline row. The migration's CHECK requires a target, and this refuses first so the failure names the caller's mistake instead of surfacing as an IntegrityError 500 from the driver. + + ``automation=True`` (WS-27z) stamps ``meta.automation``, the one flag the + timeline renders automated entries distinctly by. A FLAG, not a new + activity type or a fourth actor shape: the actor stays + ``system:workflow:`` inside the one vocabulary (D-PM-4), and the row + stays whatever type the change earns — a status move by a sweep is still a + ``status_change``. Human writes never pass it, so their meta is unchanged. """ if task_id is None and project_id is None: raise HTTPException( @@ -1137,6 +1220,8 @@ async def record_activity( status_code=422, detail=f"Unknown activity type '{activity_type}'.", ) + if automation: + meta = {**(meta or {}), "automation": True} return await insert_row(db, "pm_activities", { "type": activity_type, "task_id": task_id, @@ -1163,6 +1248,183 @@ def diff_changes(before: Any, after: Any, fields: tuple[str, ...]) -> list[dict] return changes +#: FK-valued fields a ``field_change`` may record, and where each one's human +#: label lives: field → (table, label column). WS-27w item 2 (P-5): a change +#: entry that stores only the UUID renders as a UUID the moment the row it +#: points at is renamed or deleted, so labels are resolved AT WRITE TIME and +#: stored beside the ids — history survives a lane rename without a join. +#: +#: ``status_id`` is here even though a status move is a TRANSITION with its own +#: activity type: a future call site that diffs it anyway must still resolve +#: labels rather than store bare ids. +FK_LABEL_FIELDS: dict[str, tuple[str, str]] = { + "status_id": ("pm_task_statuses", "name"), + "type_id": ("pm_task_types", "name"), + "parent_task_id": ("pm_tasks", "title"), + "project_id": ("pm_projects", "name"), + "parent_project_id": ("pm_projects", "name"), +} + +#: Fields whose consecutive same-actor edits COALESCE into the prior activity +#: row instead of appending (WS-27w item 3, P-5): an autosaving editor +#: otherwise writes dozens of rows for one editing session, and a timeline +#: that is 40 lines of "edited description" buries the one change that +#: mattered. +COALESCED_FIELDS: frozenset[str] = frozenset({"description"}) + + +async def _label_of(db: Any, table: str, column: str, row_id: Any) -> Any: + """One FK target's display label — ``None`` for a cleared or deleted end.""" + if row_id is None: + return None + row = await load_row(db, table, str(row_id)) + return None if row is None else wire(getattr(row, column, None)) + + +async def resolve_fk_labels(db: Any, changes: list[dict]) -> list[dict]: + """Rewrite FK-valued diff entries to the five-key shape the timeline owes. + + ``{field, old, new}`` stays for plain values; an entry whose field is in + :data:`FK_LABEL_FIELDS` becomes ``{field, old_id, new_id, old_label, + new_label}`` with the labels read NOW, while the referenced rows still + exist. Every ``field_change`` write goes through + :func:`record_field_change` and therefore through here — the structural + test in ``test_projects_hardening`` is what keeps that sentence true. + """ + out: list[dict] = [] + for change in changes: + source = FK_LABEL_FIELDS.get(str(change.get("field") or "")) + if source is None: + out.append(change) + continue + table, column = source + old_id, new_id = change.get("old"), change.get("new") + out.append({ + "field": change.get("field"), + "old_id": old_id, + "new_id": new_id, + "old_label": await _label_of(db, table, column, old_id), + "new_label": await _label_of(db, table, column, new_id), + }) + return out + + +async def _coalescible_prior( + db: Any, *, created_by: str, changes: list[dict], + task_id: str | None, project_id: str | None, + automation: bool = False, +) -> Any | None: + """The activity row this edit folds into, or ``None`` to append normally. + + Consecutive means exactly what WS-27w says: the IMMEDIATELY previous + activity row for this task (or project) is the same actor editing the same + lone field. Anything in between — a comment, an assignment, another + field's change, somebody else's edit — breaks the run, because the + timeline must still show that those happened in that order. + + A prior row whose meta carries anything beside ``changes`` (a revert's + ``reverted_activity_id``) is a statement of its own and is never coalesced + into or over. The one exception is WS-27z's ``automation`` flag, which + rides beside ``changes`` on every automated write: an automation that + rewrites a description every run must keep coalescing (the WS-27f rule), + so an automation-flagged prior folds an automation-flagged edit — and only + that. A human edit never folds into an automated row or vice versa, even + under the same actor string, because the flag is part of what the row + asserts. + """ + if len(changes) != 1 or str(changes[0].get("field") or "") not in COALESCED_FIELDS: + return None + if task_id is not None: + clause, target = "task_id = CAST(:target AS uuid)", task_id + elif project_id is not None: + clause, target = "project_id = CAST(:target AS uuid)", project_id + else: + return None + row = (await db.execute( + text( + f"SELECT * FROM pm_activities WHERE {clause} " + "AND deleted_at IS NULL " + "ORDER BY created_at DESC, id DESC LIMIT 1" + ), + {"target": str(target)}, + )).fetchone() + if row is None or getattr(row, "type", None) != "field_change": + return None + prior_actor = str(getattr(row, "created_by", "") or "").strip().lower() + if prior_actor != (created_by or "").strip().lower(): + return None + meta = from_jsonb(getattr(row, "meta", None)) + if not isinstance(meta, dict) or set(meta) - {"automation"} != {"changes"}: + return None + if bool(meta.get("automation")) != automation: + return None + prior = [c for c in (meta.get("changes") or []) if isinstance(c, dict)] + if len(prior) != 1 or str(prior[0].get("field") or "") != str(changes[0]["field"]): + return None + return row + + +async def record_field_change( + db: Any, + *, + created_by: str, + changes: list[dict], + task_id: str | None = None, + project_id: str | None = None, + extra_meta: dict[str, Any] | None = None, + automation: bool = False, +) -> Any: + """Write one ``field_change`` activity — THE one door (WS-27w items 2+3). + + Every caller that records a field change comes through here, and the + structural test walks the package's ``record_activity`` call sites to + refuse any that do not. That single-door shape is what the two rules hang + off: + + 1. **labels at write time** — :func:`resolve_fk_labels` runs on every + write, so an FK-valued change can never reach the table as a bare pair + of UUIDs; + 2. **description coalescing** — a same-actor consecutive edit of a + :data:`COALESCED_FIELDS` field UPDATES the prior row (its span of + ``old`` → latest ``new``, and its timestamp) instead of appending. + + ``extra_meta`` rides beside ``changes`` in the meta object; a write that + carries any (a revert naming ``reverted_activity_id``) is always appended, + never coalesced — it is an assertion about history, not an edit in a run. + """ + resolved = await resolve_fk_labels(db, changes) + if extra_meta is None: + prior = await _coalescible_prior( + db, created_by=created_by, changes=resolved, + task_id=task_id, project_id=project_id, automation=automation, + ) + if prior is not None: + prior_meta = from_jsonb(getattr(prior, "meta", None)) or {} + first = (prior_meta.get("changes") or [{}])[0] + merged = {**resolved[0], "old": first.get("old")} + coalesced: dict[str, Any] = {"changes": [merged]} + if automation: + # The flag survives the fold — a coalesced automated edit must + # not quietly turn back into a human-looking row. + coalesced["automation"] = True + # `created_at` is bumped too, not only `updated_at`: the coalesced + # row now records the LATEST edit, the timeline orders on + # `created_at`, and the row is already the task's newest — so the + # bump keeps it truthful without reordering anything. + return await update_row(db, "pm_activities", str(prior.id), { + "meta": coalesced, + "created_at": now(), + }) + meta: dict[str, Any] = {"changes": resolved} + if extra_meta: + meta.update(extra_meta) + return await record_activity( + db, activity_type="field_change", created_by=created_by, + task_id=task_id, project_id=project_id, meta=meta, + automation=automation, + ) + + # ── Events (§6.3) ─────────────────────────────────────────────────────────── async def emit(event_type: str, payload: dict[str, Any]) -> None: @@ -1232,3 +1494,54 @@ def validate_choice(value: str | None, allowed: tuple[str, ...], what: str) -> N status_code=422, detail=f"Unknown {what} '{value}'. One of: {list(allowed)}.", ) + + +#: WS-27z — the three lifecycle-policy columns (migration 166). Named once so +#: the write path's root-only guard and the tests read the same list. +LIFECYCLE_FIELDS: tuple[str, ...] = ( + "archive_after_months", "close_after_months", "timezone", +) + + +def validate_lifecycle_settings(values: dict[str, Any]) -> None: + """422 on a malformed lifecycle policy, before anything is written. + + Months are whole numbers greater than zero, or ``null`` to switch the + policy off — the migration's CHECK says the same, and refusing here names + the field instead of surfacing an IntegrityError 500. The timezone is + validated against the IANA database exactly the way the workflows app + validates a schedule trigger's (``crud._timezone_is_valid``): a bad zone + discovered by the sweep is a policy that silently measures against the + wrong midnight; discovered at save time it is a 422 the owner can act on. + """ + for column in ("archive_after_months", "close_after_months"): + if column not in values or values[column] is None: + continue + months = values[column] + if isinstance(months, bool) or not isinstance(months, int) or months <= 0: + raise HTTPException( + status_code=422, + detail=( + f"'{column}' must be a whole number of months greater " + f"than zero, or null to switch the policy off." + ), + ) + if "timezone" in values: + name = values["timezone"] + if name is None: + raise HTTPException( + status_code=422, + detail="'timezone' cannot be null — it defaults to 'UTC'.", + ) + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + try: + ZoneInfo(str(name)) + except (ZoneInfoNotFoundError, ValueError, KeyError): + raise HTTPException( + status_code=422, + detail=( + f"Unknown timezone '{name}' — use an IANA name like " + f"Asia/Kolkata" + ), + ) from None diff --git a/apps/services/gateway/gateway/routes/projects/filters.py b/apps/services/gateway/gateway/routes/projects/filters.py index 1aa370b8e..647d50bb6 100644 --- a/apps/services/gateway/gateway/routes/projects/filters.py +++ b/apps/services/gateway/gateway/routes/projects/filters.py @@ -25,10 +25,11 @@ from fastapi import HTTPException from sqlalchemy import text -#: `pm_task_statuses.category`. Mirrored from migration 146's CHECK and pinned -#: by `test_projects_filters`. +#: `pm_task_statuses.category`. Mirrored from the CHECK the migrations leave in +#: force (146, widened by 164's `triage` — WS-27u) and pinned by +#: `test_projects_filters`, which reads the LAST migration to constrain it. STATUS_CATEGORIES: tuple[str, ...] = ( - "backlog", "todo", "in_progress", "done", "cancelled", + "backlog", "todo", "in_progress", "done", "cancelled", "triage", ) #: Categories that mean "this task is finished". Shared with @@ -240,6 +241,21 @@ def build_task_filters( "status", "assignee", "project", "importance", "tag", "none", ) +#: WS-27x — the field keys a view's `shown_fields` may name. Mirrors the +#: client vocabulary (`lib/shownFields.ts`), which is the single source the +#: table's columns and the chip gate read; this tuple is the server's copy so +#: a stored view cannot accumulate junk keys. +SHOWN_FIELDS: tuple[str, ...] = ( + "status", "assignees", "start_date", "due_at", "importance", + "subtasks", "blocked", "tags", "attachments", "estimate", "created_at", +) + +#: A project's custom fields ride the same list as ``custom.`` — +#: the spelling ``patch_task`` already files a custom edit under. Checked by +#: SHAPE rather than against the registry: this function is pure, and a view +#: must survive its field being deleted after the save. +_CUSTOM_FIELD_PREFIX = "custom." + def normalise_view_config(config: Any) -> dict[str, Any]: """A stored view's config, reduced to what the board can actually apply. @@ -262,10 +278,46 @@ def normalise_view_config(config: Any) -> dict[str, Any]: if key in VIEW_FILTER_KEYS } if isinstance(raw, dict) else {} group_by = config.get("group_by") - return { + out: dict[str, Any] = { "filters": filters, "group_by": group_by if group_by in GROUP_BY else "status", } + # WS-27y — lane state rides the same config. The client's rules mirrored + # exactly (grouping.ts `fromConfig`): a sub-axis equal to the main axis is + # nonsense and is dropped, lane keys must be strings, and the flags are + # stored only when they say something — so a lane-less view stays + # byte-identical to one saved before lanes existed. + sub = config.get("sub_group_by") + if sub in GROUP_BY and sub != "none" and sub != out["group_by"]: + out["sub_group_by"] = sub + lanes = config.get("collapsed_lanes") + if isinstance(lanes, list): + kept = [key for key in lanes if isinstance(key, str)] + if kept: + out["collapsed_lanes"] = kept + if config.get("show_empty_lanes") is True: + out["show_empty_lanes"] = True + # WS-27x — the shown-fields set. The client's rules mirrored exactly + # (`shownFields.sanitizeShownFields`): a list of known field keys, with + # unknown and non-string entries dropped and duplicates collapsed to the + # first appearance. ABSENT (or not a list) stays absent — the default set + # is the client's to apply, and writing it here would freeze today's + # default into every stored view. An explicitly stored empty list is KEPT: + # "every column hidden" is a choice, not the default. + shown = config.get("shown_fields") + if isinstance(shown, list): + kept_fields: list[str] = [] + for key in shown: + if not isinstance(key, str) or key in kept_fields: + continue + known = key in SHOWN_FIELDS or ( + key.startswith(_CUSTOM_FIELD_PREFIX) + and len(key) > len(_CUSTOM_FIELD_PREFIX) + ) + if known: + kept_fields.append(key) + out["shown_fields"] = kept_fields + return out #: Assignees for a page of tasks, in ONE query. diff --git a/apps/services/gateway/gateway/routes/projects/intake.py b/apps/services/gateway/gateway/routes/projects/intake.py new file mode 100644 index 000000000..24ed9b5e9 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/intake.py @@ -0,0 +1,544 @@ +"""Projects · intake — the front door (WS-27u). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1 (WS-27u); +rationale in ``plane_pm_research_2026-08.md`` §3 (P-1), behind the AGPL wall — +the shape here is this package's own idiom, never a translation. + + POST /projects/intake → capture: task + wrapper, one transaction + GET /projects/intake → the queue (pending, and snoozes past due) + POST /projects/intake/{task_id}/accept → flip the task's status IN PLACE + POST /projects/intake/{task_id}/decline → archive; the wrapper stays as provenance + POST /projects/intake/{task_id}/duplicate → point at the original; archive + POST /projects/intake/{task_id}/snooze → hide from the queue until an instant + +**A captured task is real from birth.** Capture writes an ordinary ``pm_tasks`` +row — commentable, assignable, linkable from the first second — parked in a +status whose category is ``triage``, which is what keeps it out of every +board/list/calendar/timeline/search read (``core.triage_exclusion_clause``, +the ONE copy of that predicate) until somebody rules on it. + +**Accept flips the status in place, never copies.** The task keeps its id, its +number, its timeline and anything anyone attached while it was parked. A +copy-on-accept design forks the history at exactly the moment it starts to +matter. + +**The wrapper is permanent provenance.** All four rulings UPDATE the +``pm_intake`` row; nothing here deletes it — "where did this come from and who +ruled on it" must stay a point read forever. Decline and duplicate archive the +TASK (the standing soft-delete this app already has) and leave the wrapper +naming why. + +**Snooze needs no scheduler.** The queue read is +``snoozed_until <= now()`` — the row reappears by being read, not by being +woken, so the feature costs no worker and no cron (§5's non-goals: /workflows +is the only engine). + +**Visibility is the tasks' own** (R5): the queue and every action go through +``task_visibility_clause`` / ``load_visible_task``, so an item outside the +caller's grants is a 404, never a 403, and the queue can never show a capture +its reader could not open. + +**Not in scope** (per the ticket): routing rules — auto-accept, agent +screening. Those are ``/workflows`` nodes (D6), added when email capture +(§6.5) lands. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException +from gateway.routes.projects.core import ( + TASK_SOURCES, + TRIAGE_CATEGORY, + Page, + TaskModel, + _get_db, + actor, + apply_status_transition, + emit, + insert_row, + load_default_status, + load_visible_project, + load_visible_task, + next_task_number, + now, + record_activity, + require_status_in_project, + resolve_visibility, + root_project_id, + router, + row_to_dict, + task_visibility_clause, + update_row, + wire, +) +from gateway.routes.projects.filters import parse_when +from pydantic import BaseModel +from sqlalchemy import text + + +class IntakeIn(BaseModel): + """The capture payload. Title and project only — the front door asks the + question a capture can answer, not the six a create form would.""" + + project_id: str | None = None + title: str | None = None + description: str | None = None + importance: int | None = None + due_at: str | None = None + #: Where this came from — free text ('email', 'slack', 'api', …) stored on + #: the WRAPPER. When it happens to be a legal `pm_tasks.source` value it is + #: stamped on the task too, so "captured from email" reads the same as an + #: email-created task everywhere else. + source: str | None = None + source_ref: str | None = None + + +class AcceptIn(BaseModel): + #: The destination lane. Omitted, the project's default status is used — + #: the same answer `POST /tasks` gives a task created with no status. + status_id: str | None = None + + +class DuplicateIn(BaseModel): + duplicate_of_task_id: str + + +class SnoozeIn(BaseModel): + until: str + + +class IntakeModel(BaseModel): + """The wrapper, 1:1 with `pm_intake`'s columns so `row_to_dict` maps it.""" + + id: str + task_id: str + status: str + snoozed_until: str | None = None + duplicate_of_task_id: str | None = None + source: str | None = None + source_ref: str | None = None + created_by: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +#: The queue states — the two from which a ruling is legal. The other three +#: are terminal: ruling twice would write two contradictory provenance trails. +QUEUE_STATES: frozenset[str] = frozenset({"pending", "snoozed"}) + +#: The queue read: everything visible, unarchived, and either pending or +#: snoozed past its `snoozed_until`. The reappearance is the comparison — +#: nothing flips the row back, so a snooze needs no worker. +_QUEUE_SQL = """ +SELECT t.*, + i.id AS intake_id, + i.status AS intake_status, + i.snoozed_until AS intake_snoozed_until, + i.source AS intake_source, + i.source_ref AS intake_source_ref, + i.duplicate_of_task_id AS intake_duplicate_of_task_id, + i.created_at AS intake_created_at + FROM pm_tasks t + JOIN pm_intake i ON i.task_id = t.id + WHERE {visible} + AND t.archived_at IS NULL + AND (i.status = 'pending' + OR (i.status = 'snoozed' AND i.snoozed_until <= now())) + {project_scope} + ORDER BY i.created_at, t.id + LIMIT :limit OFFSET :offset +""" + +_QUEUE_COUNT_SQL = """ +SELECT count(*) + FROM pm_tasks t + JOIN pm_intake i ON i.task_id = t.id + WHERE {visible} + AND t.archived_at IS NULL + AND (i.status = 'pending' + OR (i.status = 'snoozed' AND i.snoozed_until <= now())) + {project_scope} +""" + +#: The queue's project filter covers the SUBTREE — a project's front door +#: receives captures aimed at any of its subprojects, the same closure the +#: task list's `include_subtree` walks. +_SUBTREE_SCOPE = ( + "AND t.project_id IN (" + " WITH RECURSIVE sub AS (" + " SELECT id FROM pm_projects WHERE id = CAST(:pid AS uuid)" + " UNION ALL" + " SELECT p.id FROM pm_projects p JOIN sub s" + " ON p.parent_project_id = s.id" + " ) SELECT id FROM sub)" +) + + +async def load_triage_status(db: Any, root_id: str) -> Any: + """The root's triage-category status, provisioned on first use. + + Provisioned HERE rather than seeded on every root (`tree._seed_root`): + most projects never use intake, and a "Triage" lane on every fresh board + would advertise a feature nobody asked that project to have. The insert + follows the seed's own shape. Position 5 puts it above Backlog (10) on the + admin list without ever being the `load_default_status` fallback winner in + a seeded project — and it is never `is_default`, so a normally-created + task can never land in it by omission. + """ + row = (await db.execute( + text( + "SELECT * FROM pm_task_statuses " + "WHERE project_id = CAST(:root AS uuid) " + f"AND category = '{TRIAGE_CATEGORY}' " + "ORDER BY position, name LIMIT 1" + ), + {"root": root_id}, + )).fetchone() + if row is not None: + return row + return await insert_row(db, "pm_task_statuses", { + "project_id": root_id, "name": "Triage", "color": "gray", + "position": 5, "category": TRIAGE_CATEGORY, "is_default": False, + }) + + +async def _load_wrapper(db: Any, task_id: str) -> Any: + """The task's wrapper, or 404. + + Reached only AFTER `load_visible_task` has said yes, so this 404 means + "that task never came through the front door" — R5 already collapsed + "not yours" into the task load above it. + """ + row = (await db.execute( + text("SELECT * FROM pm_intake WHERE task_id = CAST(:tid AS uuid)"), + {"tid": task_id}, + )).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Intake item not found") + return row + + +def _require_queue_state(wrapper: Any) -> None: + """A ruling is legal only from the queue states — a second ruling on a + terminal wrapper would overwrite the provenance the first one wrote.""" + state = getattr(wrapper, "status", None) + if state not in QUEUE_STATES: + raise HTTPException( + status_code=422, + detail=f"This intake item was already resolved ('{state}').", + ) + + +def _shape(task_row: Any, wrapper_row: Any) -> dict[str, Any]: + return { + "task": row_to_dict(task_row, TaskModel), + "intake": row_to_dict(wrapper_row, IntakeModel), + } + + +# ── Capture ───────────────────────────────────────────────────────────────── + +@router.post("/intake", status_code=201) +async def capture_intake( + payload: IntakeIn, user: UserContext = Depends(get_current_user), +) -> dict: + """Create the task and its wrapper in ONE transaction. + + One transaction is the contract, not an optimisation: a task without a + wrapper is a capture with no provenance, and a wrapper without a task + points at nothing — neither half may ever exist alone, so there is exactly + one commit and it covers both writes plus the timeline entry. + """ + title = (payload.title or "").strip() + if not title: + raise HTTPException(status_code=422, detail="A capture needs a title.") + if not payload.project_id: + raise HTTPException(status_code=422, detail="A capture needs a project_id.") + + source = (payload.source or "").strip() or None + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + # R5 — capturing INTO a project requires seeing it; an unreadable id + # answers 404, never 403. + await load_visible_project(db, vis, str(payload.project_id)) + root = await root_project_id(db, str(payload.project_id)) + status = await load_triage_status(db, root) + + task = await insert_row(db, "pm_tasks", { + "project_id": str(payload.project_id), + "root_project_id": root, + "status_id": str(status.id), + "title": title, + "description": payload.description, + "importance": payload.importance, + "due_at": payload.due_at, + "task_number": await next_task_number(db, root), + "created_by": actor(user), + # The task's own source speaks the CHECK'd vocabulary; the + # wrapper's is free text. When they coincide, both say it. + **({"source": source} if source in TASK_SOURCES else {}), + }) + wrapper = await insert_row(db, "pm_intake", { + "task_id": str(task.id), + "status": "pending", + "source": source, + "source_ref": (payload.source_ref or "").strip() or None, + "created_by": actor(user), + }) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=str(task.id), body="Captured to intake", + meta={"intake": "captured", "source": source, + "source_ref": getattr(wrapper, "source_ref", None)}, + ) + await db.commit() + result = _shape(task, wrapper) + finally: + await db.close() + + await emit("pm.intake.captured", { + "task_id": result["task"]["id"], + "project_id": str(payload.project_id), + "title": title, + "source": source, + }) + return result + + +# ── The queue ─────────────────────────────────────────────────────────────── + +@router.get("/intake") +async def list_intake( + user: UserContext = Depends(get_current_user), + project_id: str | None = None, + page: Page = Depends(), +) -> dict: + """The front-door queue: pending items, plus snoozes whose time has come. + + Scoped by the SAME grants as the tasks it wraps — `task_visibility_clause` + is the whole gate, so the queue can never show a capture its reader could + not open, and a `project_id` the caller cannot see is a 404 (R5) rather + than an empty queue that confirms the project exists. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + params: dict[str, Any] = dict(vis.params) + scope = "" + if project_id: + await load_visible_project(db, vis, project_id) + scope = _SUBTREE_SCOPE + params["pid"] = project_id + + visible = task_visibility_clause(vis) + total = (await db.execute( + text(_QUEUE_COUNT_SQL.format(visible=visible, project_scope=scope)), + params, + )).scalar() or 0 + rows = (await db.execute( + text(_QUEUE_SQL.format(visible=visible, project_scope=scope)), + {**params, "limit": page.limit, "offset": page.offset}, + )).fetchall() + + out = [] + for row in rows: + item = row_to_dict(row, TaskModel) + item["intake"] = { + "id": str(row.intake_id), + "task_id": item["id"], + "status": row.intake_status, + "snoozed_until": wire(row.intake_snoozed_until), + "source": row.intake_source, + "source_ref": row.intake_source_ref, + "duplicate_of_task_id": ( + str(row.intake_duplicate_of_task_id) + if row.intake_duplicate_of_task_id is not None else None + ), + "created_at": wire(row.intake_created_at), + } + out.append(item) + return {"rows": out, "total": int(total)} + finally: + await db.close() + + +# ── The four rulings ──────────────────────────────────────────────────────── + +@router.post("/intake/{task_id}/accept") +async def accept_intake( + task_id: str, payload: AcceptIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Accept: flip the task's status IN PLACE — never copy. + + The move goes through `apply_status_transition`, the same three-effect + helper every other status write uses, so accepting writes the same + `status_change` activity a board drag would. The wrapper flips to + `accepted` and stays forever. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + wrapper = await _load_wrapper(db, task_id) + _require_queue_state(wrapper) + + root = str(task.root_project_id) + destination = ( + await require_status_in_project(db, root, str(payload.status_id)) + if payload.status_id + else await load_default_status(db, root) + ) + # Accepting INTO triage is a ruling that rules nothing — refused + # BEFORE anything is written, so the guard also catches a project + # whose fallback default happens to be the triage lane itself. + if getattr(destination, "category", None) == TRIAGE_CATEGORY: + raise HTTPException( + status_code=422, + detail="Accepting must move the task out of triage; " + "pick a non-triage status.", + ) + moved = await apply_status_transition( + db, task, str(destination.id), created_by=actor(user), + ) + wrapper = await update_row(db, "pm_intake", str(wrapper.id), { + "status": "accepted", "snoozed_until": None, + }) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Accepted from intake", + meta={"intake": "accepted", "status_id": str(destination.id)}, + ) + await db.commit() + result = _shape(moved["row"], wrapper) + finally: + await db.close() + + await emit("pm.intake.accepted", {"task_id": task_id}) + return result + + +@router.post("/intake/{task_id}/decline") +async def decline_intake( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Decline: archive the task; the wrapper stays as the reason it exists. + + Archived, not deleted — the standing soft-delete, so a decline is + revertible the way any archive is, and the capture's timeline survives. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + wrapper = await _load_wrapper(db, task_id) + _require_queue_state(wrapper) + + task = await update_row(db, "pm_tasks", task_id, {"archived_at": now()}) + wrapper = await update_row(db, "pm_intake", str(wrapper.id), { + "status": "declined", "snoozed_until": None, + }) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Declined at intake", + meta={"intake": "declined"}, + ) + await db.commit() + result = _shape(task, wrapper) + finally: + await db.close() + + await emit("pm.intake.declined", {"task_id": task_id}) + return result + + +@router.post("/intake/{task_id}/duplicate") +async def duplicate_intake( + task_id: str, payload: DuplicateIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Duplicate: record WHICH task this repeats, then archive it. + + The original must be visible to the caller — an unreadable target is a + 404 (R5), which is also what keeps a wrapper from pointing across a grant + boundary the caller cannot see over. + """ + original = str(payload.duplicate_of_task_id) + if original == str(task_id): + raise HTTPException( + status_code=422, detail="A task cannot be a duplicate of itself.", + ) + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + await load_visible_task(db, vis, original) + wrapper = await _load_wrapper(db, task_id) + _require_queue_state(wrapper) + + task = await update_row(db, "pm_tasks", task_id, {"archived_at": now()}) + wrapper = await update_row(db, "pm_intake", str(wrapper.id), { + "status": "duplicate", "duplicate_of_task_id": original, + "snoozed_until": None, + }) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Marked duplicate at intake", + meta={"intake": "duplicate", "duplicate_of_task_id": original}, + ) + await db.commit() + result = _shape(task, wrapper) + finally: + await db.close() + + await emit("pm.intake.duplicate", { + "task_id": task_id, "duplicate_of_task_id": original, + }) + return result + + +@router.post("/intake/{task_id}/snooze") +async def snooze_intake( + task_id: str, payload: SnoozeIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Snooze: hide from the queue until `until`, then reappear on its own. + + Reappearance is the queue's `snoozed_until <= now()` comparison — no + worker, no cron, nothing to wake. An instant in the past is refused: it + would be a snooze that never hid anything, which is a no-op wearing a + ruling's clothes. + """ + until = parse_when(payload.until, field="until") + if until <= now(): + raise HTTPException( + status_code=422, detail="'until' must be in the future.", + ) + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + wrapper = await _load_wrapper(db, task_id) + _require_queue_state(wrapper) + + wrapper = await update_row(db, "pm_intake", str(wrapper.id), { + "status": "snoozed", "snoozed_until": until, + }) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Snoozed at intake", + meta={"intake": "snoozed", "until": until.isoformat()}, + ) + await db.commit() + result = _shape(task, wrapper) + finally: + await db.close() + + await emit("pm.intake.snoozed", { + "task_id": task_id, "until": until.isoformat(), + }) + return result diff --git a/apps/services/gateway/gateway/routes/projects/notifications.py b/apps/services/gateway/gateway/routes/projects/notifications.py index a2680d66d..0d5434c74 100644 --- a/apps/services/gateway/gateway/routes/projects/notifications.py +++ b/apps/services/gateway/gateway/routes/projects/notifications.py @@ -109,6 +109,19 @@ def mention_targets(body: str | None) -> list[str]: return list(seen) +def new_mentions(old_body: str | None, new_body: str | None) -> list[str]: + """Only the addresses an edit ADDED, in first-seen order (WS-27v). + + Editing a comment or description must notify the *newly named* people and + nobody twice: without this diff, fixing a typo in a comment that mentions + three colleagues re-pings all three, and a bell that repeats itself is a + bell people mute. Set-differenced on the folded address, the same identity + every other mention read uses (R10). + """ + before = set(mention_targets(old_body)) + return [who for who in mention_targets(new_body) if who not in before] + + def excerpt_of(body: str | None) -> str | None: """A single-line snippet, ellipsised. @@ -219,20 +232,27 @@ async def notify( async def task_audience(db: Any, task_id: str) -> list[str]: - """Who a comment on this task concerns: its assignees and its author. - - Derived rather than subscribed. A `pm_task_watchers` table would be the - fuller answer and is not this ticket — and the derived audience is the one - a watcher list would be seeded with anyway, so nothing here has to be undone - when one arrives. + """Who an event on this task concerns: watchers ∪ assignees (WS-27v). + + The watcher table arrived exactly as WS-27j predicted, seeded from the + audience it replaces (migration 165 subscribes every task's author), so the + author keeps hearing without being a special case here. Assignees stay in + the audience in their OWN right rather than through a watcher row: holding + the work is the claim, and unwatching must not silence an assignment. + + This is the audience, not the delivery list — ``notify`` still drops the + actor and agents (rules 1 and 2) and filters every recipient through + ``resolve_visibility_for`` (rule 3). A watcher who has lost the project's + grant keeps their row and hears nothing, which is the deliberate divergence + from Plane's membership-only check. """ rows = (await db.execute( text( - "SELECT assignee AS who FROM pm_task_assignees " + "SELECT watcher AS who FROM pm_task_watchers " "WHERE task_id = CAST(:tid AS uuid) " "UNION " - "SELECT created_by AS who FROM pm_tasks " - "WHERE id = CAST(:tid AS uuid)" + "SELECT assignee AS who FROM pm_task_assignees " + "WHERE task_id = CAST(:tid AS uuid)" ), {"tid": task_id}, )).fetchall() @@ -285,10 +305,15 @@ async def list_notifications( {"me": me, "limit": page.limit, "offset": page.offset, **vis.params}, )).fetchall() # Counted with the same visibility clause, so the badge can never - # promise more than the list can show. + # promise more than the list can show. WS-27v splits the count in the + # SAME query rather than a second one: `mentions` is a subset of + # `total` by construction here, so the two numbers can never disagree + # about which rows they describe. unread = (await db.execute( text( - "SELECT count(*) AS n FROM pm_notifications n " + "SELECT count(*) AS total, " + "count(*) FILTER (WHERE n.kind = 'mention') AS mentions " + "FROM pm_notifications n " "JOIN pm_tasks t ON t.id = n.task_id " f"WHERE n.recipient = :me AND n.read_at IS NULL AND {clause}" ), @@ -299,7 +324,13 @@ async def list_notifications( return { "rows": [_row(r) for r in rows], "total": len(rows), - "unread": int(getattr(unread, "n", 0) or 0), + # `{total, mentions}`, not a bare number: "you were named" is a + # stronger claim on attention than "something you follow moved", and + # the bell renders the two distinctly (P-20). + "unread": { + "total": int(getattr(unread, "total", 0) or 0), + "mentions": int(getattr(unread, "mentions", 0) or 0), + }, } diff --git a/apps/services/gateway/gateway/routes/projects/search.py b/apps/services/gateway/gateway/routes/projects/search.py index b8d29dcaf..39e74cf07 100644 --- a/apps/services/gateway/gateway/routes/projects/search.py +++ b/apps/services/gateway/gateway/routes/projects/search.py @@ -33,6 +33,13 @@ **LIKE metacharacters are escaped** — see `like_escape`. That was a live defect on the list endpoint too, not a new-code precaution. +**Pickers pass `?exclude_relatives_of=`** (WS-27w item 5). A parent/ +relation/duplicate picker anchored on a task must not offer the task itself, +its ancestors, its descendants, or anything already related in either +direction — every one of those is a choice the write path will 422, and a +picker that offers refusals teaches people the picker is broken. The write-time +guards stay; this is their read-side twin. + **Comments are deliberately not searched.** They are the largest text in the system and the least likely to be what somebody is looking for by name; a comment hit would also have to be rendered as its task, which makes ranking @@ -41,13 +48,18 @@ from __future__ import annotations +from typing import Any + from acb_auth import UserContext, get_current_user from fastapi import Depends from gateway.routes.projects.core import ( + MAX_DEPTH, _get_db, + load_visible_task, resolve_visibility, router, task_visibility_clause, + triage_exclusion_clause, wire, ) from gateway.routes.projects.filters import like_escape @@ -111,7 +123,8 @@ def task_number(raw: str) -> int | None: JOIN pm_projects p ON p.id = t.project_id JOIN pm_task_statuses s ON s.id = t.status_id WHERE {visible} - AND t.archived_at IS NULL + AND {triage} + AND t.archived_at IS NULL{exclude} AND (t.title ILIKE :term OR t.description ILIKE :term OR (CAST(:number AS bigint) IS NOT NULL @@ -121,10 +134,88 @@ def task_number(raw: str) -> int | None: """ +#: The clause `exclude_relatives_of` adds (WS-27w item 5, P-7). A fragment +#: rather than a second statement, so everything else about search — ranking, +#: visibility, the archived rule, the cap — is exactly the same query. +_EXCLUDE_SQL = "\n AND NOT (t.id = ANY(CAST(:excluded AS uuid[])))" + + +async def relative_task_ids(db: Any, task_id: str) -> set[str]: + """Every task a picker anchored on ``task_id`` must not offer. + + Four classes, matching the write-time guards one for one: the task itself, + its ancestors, its descendants (all three are what `assert_no_task_cycle` + would 422), and anything already related in EITHER direction (what the + link upsert would merely re-assert). The write-time guards stay untouched + — this is their read-side twin, so a relation/duplicate/parent picker + cannot offer a choice the write path will refuse. + + Walks are bounded by :data:`MAX_DEPTH` for the cycle checks' reason: an + unbounded walk over data somebody can create is a denial-of-service + surface, and a real hierarchy never approaches the bound. + """ + anchor = str(task_id) + out: set[str] = {anchor} + + # Ancestors — one hop at a time, the same walk `assert_no_task_cycle` does. + current = anchor + for _ in range(MAX_DEPTH): + row = (await db.execute( + text("SELECT parent_task_id FROM pm_tasks WHERE id = CAST(:id AS uuid)"), + {"id": current}, + )).fetchone() + parent = getattr(row, "parent_task_id", None) if row is not None else None + if parent is None or str(parent) in out: + break + out.add(str(parent)) + current = str(parent) + + # Descendants — frontier by frontier, the `assert_no_block_cycle` shape. + frontier = {anchor} + for _ in range(MAX_DEPTH): + if not frontier: + break + rows = (await db.execute( + text( + "SELECT id FROM pm_tasks " + "WHERE parent_task_id = ANY(CAST(:ids AS uuid[]))" + ), + {"ids": sorted(frontier)}, + )).fetchall() + frontier = {str(r.id) for r in rows} - out + out |= frontier + + # Already related, both directions — two single-column reads rather than + # one OR, because a link is readable from either side and the picker must + # exclude it from either side too. + for column, other in ( + ("source_task_id", "target_task_id"), + ("target_task_id", "source_task_id"), + ): + rows = (await db.execute( + text( + f"SELECT * FROM pm_task_links " + f"WHERE {column} = CAST(:tid AS uuid)" + ), + {"tid": anchor}, + )).fetchall() + out |= { + str(getattr(r, other)) for r in rows + if getattr(r, other, None) is not None + } + return out + + @router.get("/search") async def search_tasks( q: str = "", limit: int = MAX_HITS, + exclude_relatives_of: str | None = None, + # WS-27u. Search reaches every task in the app, so it is the surface where + # leaking the intake queue costs most — and the surface the duplicate + # picker needs the flag on, since "is this already captured" is a question + # about triage-parked tasks specifically. One predicate, in core. + include_triage: bool = False, user: UserContext = Depends(get_current_user), ) -> dict: """Ranked task hits across every project the caller can see. @@ -143,10 +234,26 @@ async def search_tasks( db = await _get_db() try: vis = await resolve_visibility(db, user) + exclude_sql = "" + exclude_params: dict[str, Any] = {} + if exclude_relatives_of: + # The anchor must be visible to the caller — an unreadable id is a + # 404 (R5), exactly as it is on the list endpoint's project filter, + # so the parameter cannot be used to probe for existence. + await load_visible_task(db, vis, exclude_relatives_of) + exclude_sql = _EXCLUDE_SQL + exclude_params["excluded"] = sorted( + await relative_task_ids(db, exclude_relatives_of) + ) rows = (await db.execute( - text(_SEARCH_SQL.format(visible=task_visibility_clause(vis))), + text(_SEARCH_SQL.format( + visible=task_visibility_clause(vis), + triage="TRUE" if include_triage else triage_exclusion_clause(), + exclude=exclude_sql, + )), { **vis.params, + **exclude_params, "term": f"%{escaped}%", "prefix": f"{escaped}%", "number": task_number(term), @@ -183,4 +290,4 @@ async def search_tasks( await db.close() -__all__ = ["MAX_HITS", "MIN_QUERY", "task_number"] +__all__ = ["MAX_HITS", "MIN_QUERY", "relative_task_ids", "task_number"] diff --git a/apps/services/gateway/gateway/routes/projects/tasks.py b/apps/services/gateway/gateway/routes/projects/tasks.py index bd4d81563..b37d5094c 100644 --- a/apps/services/gateway/gateway/routes/projects/tasks.py +++ b/apps/services/gateway/gateway/routes/projects/tasks.py @@ -24,6 +24,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.projects.core import ( + CLOSING_CATEGORIES, DIRECTIONS, TASK_SORTS, TASK_SOURCES, @@ -46,13 +47,17 @@ load_visible_project, load_visible_task, next_task_number, + now, record_activity, + record_field_change, + require_row, require_status_in_project, resolve_visibility, root_project_id, router, row_to_dict, task_visibility_clause, + triage_exclusion_clause, update_row, validate_choice, ) @@ -62,7 +67,12 @@ attach_relation_counts, build_task_filters, ) -from gateway.routes.projects.notifications import notify +from gateway.routes.projects.notifications import ( + excerpt_of, + new_mentions, + notify, +) +from gateway.routes.projects.watchers import ensure_watchers from gateway.routes.projects.relations import ( DIRECTED_TYPES, assert_no_block_cycle, @@ -127,6 +137,9 @@ async def list_tasks( # WS-27m. `tags` is ANY, `tags_all` is ALL — see `build_task_filters`. tags: str | None = None, tags_all: str | None = None, + # WS-27u. Triage-parked tasks are invisible to every list surface unless + # asked for — the ONE predicate lives in `core.triage_exclusion_clause`. + include_triage: bool = False, ) -> ListResponse: """The one task-list endpoint every surface reads through. @@ -188,15 +201,21 @@ async def list_tasks( ) clauses.extend(extra_clauses) params.update(extra_params) + if not include_triage: + clauses.append(triage_exclusion_clause()) where = " WHERE " + " AND ".join(clauses) total = (await db.execute( text(f"SELECT count(*) FROM pm_tasks t{where}"), params, )).scalar() or 0 + # `column` is an allowlisted template with `{dir}` slots; the direction + # is one of OUR two words, never caller text. Every entry ends with the + # `(created_at, id)` tiebreaker (core.SORT_TIEBREAK), so the order is + # total and a tie cannot straddle a page boundary. rows = (await db.execute( text( f"SELECT t.* FROM pm_tasks t{where} " - f"ORDER BY {column} {order} NULLS LAST, t.id {order} " + f"ORDER BY {column.format(dir=order)} " f"LIMIT :limit OFFSET :offset" ), {**params, "limit": page.limit, "offset": page.offset}, @@ -286,6 +305,10 @@ async def create_task( db, activity_type="system", created_by=actor(user), task_id=task_id, body="Task created", ) + # WS-27v — the creator watches their own task, which is what keeps the + # WS-27j author-hears-about-comments behaviour once the audience is + # watchers ∪ assignees (migration 165 seeds the same for older tasks). + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) await db.commit() result = row_to_dict(row, TaskModel) finally: @@ -365,9 +388,11 @@ async def patch_task( for key, moved in sorted(custom_changes.items()) ) if changes: - await record_activity( - db, activity_type="field_change", created_by=actor(user), - task_id=task_id, meta={"changes": changes}, + # Through the ONE field_change door (WS-27w): FK ids gain their + # labels at write time, and a same-actor consecutive + # description edit coalesces into the prior row. + await record_field_change( + db, created_by=actor(user), task_id=task_id, changes=changes, ) moved = None if new_status is not None and str(new_status) != str(before.status_id): @@ -376,6 +401,28 @@ async def patch_task( ) after = moved["row"] + if values or moved is not None: + # WS-27v — editing a task subscribes the editor (idempotent). Only + # when something actually changed: a no-op PATCH is not a touch. + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) + if "description" in values: + # WS-27v — mention DIFFING on the description, same rule as a + # comment edit: only the addresses this edit ADDED are notified, so + # rewording a description that already names two colleagues pings + # neither, and the newly delivered mentions become watchers. + # Watchers at large deliberately hear nothing about a description + # edit — the diffed mentions are the whole fan-out. + added = new_mentions( + getattr(before, "description", None), values["description"], + ) + mentioned = await notify( + db, recipients=added, kind="mention", task_id=task_id, + actor_id=actor(user), excerpt=excerpt_of(values["description"]), + ) + await ensure_watchers( + db, task_id, mentioned["notified"], by=actor(user), + ) + await db.commit() result = row_to_dict(after, TaskModel) finally: @@ -505,6 +552,91 @@ async def delete_task( ) +# ── Archive (WS-27w item 1) ───────────────────────────────────────────────── + +@router.post("/tasks/{task_id}/archive") +async def archive_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Archive one task — allowed only once it is CLOSED. + + An archived task exits every default list, board, calendar and search + surface at once, so archiving an open task is a trap, not a feature (P-3): + the work disappears while still owed, and nobody gardening a board can see + where it went. The guard is written on the status CATEGORY, and as "not in + (done, cancelled)" rather than as a list of open categories — a category + added later (WS-27u's `triage`) is refused by default instead of becoming + silently archivable. The refusal names the actual category, because "cannot + archive" without the why sends people hunting through lanes. + + WS-27z's sweeper depends on this guard shipping first. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + status = await require_row( + db, "pm_task_statuses", str(task.status_id), "Status", + ) + category = str(getattr(status, "category", "") or "") + if category not in CLOSING_CATEGORIES: + raise HTTPException( + status_code=422, + detail=( + f"Cannot archive an open task: its status category is " + f"'{category}'. Move it to a done or cancelled status " + f"first." + ), + ) + if getattr(task, "archived_at", None) is not None: + # Already archived — idempotent, the double-click answer. + return row_to_dict(task, TaskModel) + row = await update_row(db, "pm_tasks", task_id, {"archived_at": now()}) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Task archived", + ) + await db.commit() + result = row_to_dict(row, TaskModel) + finally: + await db.close() + + await emit("pm.task.archived", {"task_id": task_id}) + return result + + +@router.post("/tasks/{task_id}/unarchive") +async def unarchive_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Bring an archived task back onto its board. + + No category guard in this direction — restoring puts work back where + people can see it, which is never the trap the archive guard exists to + prevent. Without this endpoint an archive would be one-way: nothing else + writes ``archived_at``, and the PATCH surface deliberately does not accept + it. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + if getattr(task, "archived_at", None) is None: + return row_to_dict(task, TaskModel) + row = await update_row(db, "pm_tasks", task_id, {"archived_at": None}) + await record_activity( + db, activity_type="system", created_by=actor(user), + task_id=task_id, body="Task restored from the archive", + ) + await db.commit() + result = row_to_dict(row, TaskModel) + finally: + await db.close() + + await emit("pm.task.unarchived", {"task_id": task_id}) + return result + + # ── Assignees ─────────────────────────────────────────────────────────────── @router.put("/tasks/{task_id}/assignees") @@ -579,6 +711,12 @@ async def set_assignees( db, recipients=sorted(added), kind="assigned", task_id=task_id, actor_id=actor(user), ) + # WS-27v — a newly added assignee becomes a watcher. Idempotent, and + # only `added` for the same reason the event carries added: a re-assert + # is not a touch. Agents are dropped inside the helper (they are + # dispatched, never subscribed), and the rows survive a later + # unassignment — having held the work is a reason to keep hearing. + await ensure_watchers(db, task_id, sorted(added), by=actor(user)) await db.commit() finally: await db.close() diff --git a/apps/services/gateway/gateway/routes/projects/tree.py b/apps/services/gateway/gateway/routes/projects/tree.py index 7af1db0f1..abedeee04 100644 --- a/apps/services/gateway/gateway/routes/projects/tree.py +++ b/apps/services/gateway/gateway/routes/projects/tree.py @@ -27,6 +27,7 @@ from acb_auth import UserContext, get_current_user from fastapi import Depends, HTTPException from gateway.routes.projects.core import ( + LIFECYCLE_FIELDS, PROJECT_SOURCES, PROJECT_STATUSES, GrantModel, @@ -42,6 +43,7 @@ insert_row, load_visible_project, record_activity, + record_field_change, require_organization, resolve_visibility, root_project_id, @@ -50,6 +52,7 @@ update_row, validate_choice, validate_grant_subject, + validate_lifecycle_settings, ) from pydantic import BaseModel from sqlalchemy import text @@ -59,8 +62,32 @@ #: diff nobody reads. _TRACKED_PROJECT_FIELDS: tuple[str, ...] = ( "name", "description", "status", "lead", "parent_project_id", + # WS-27z — a lifecycle-policy change is exactly the edit somebody asks + # "who turned this on, and when" about, six months later. + "archive_after_months", "close_after_months", "timezone", ) + +def _refuse_lifecycle_on_child(values: dict, parent_project_id: object) -> None: + """WS-27z — the policy is a ROOT-project setting; the subtree inherits. + + Statuses, types, custom fields and tags already work this way (root-keyed, + subtree-wide), and the sweep acts on ``pm_tasks.root_project_id`` — so a + value on a child row would be inert. Refusing the write keeps the inert + case unreachable rather than merely documented. + """ + if parent_project_id is None: + return + offered = [f for f in LIFECYCLE_FIELDS if f in values] + if offered: + raise HTTPException( + status_code=422, + detail=( + f"{offered} are root-project settings — the root's lifecycle " + f"policy governs its whole subtree. Set them on the root." + ), + ) + #: Seeded on every ROOT project. The owner reshapes these in the app; they exist #: so a new project has a working board on its first render rather than an empty #: status picker. @@ -207,6 +234,8 @@ async def create_node( raise HTTPException(status_code=422, detail="A project needs a name.") validate_choice(values.get("status"), PROJECT_STATUSES, "project status") validate_choice(values.get("source"), PROJECT_SOURCES, "source") + validate_lifecycle_settings(values) + _refuse_lifecycle_on_child(values, values.get("parent_project_id")) values["name"] = name values["created_by"] = actor(user) @@ -267,6 +296,7 @@ async def patch_node( values = clean_payload(payload) validate_choice(values.get("status"), PROJECT_STATUSES, "project status") validate_choice(values.get("source"), PROJECT_SOURCES, "source") + validate_lifecycle_settings(values) # Re-parenting is a MOVE, with its own cycle check and root re-stamping. # Accepting it here as an ordinary field would skip both. if "parent_project_id" in values: @@ -279,14 +309,21 @@ async def patch_node( try: vis = await resolve_visibility(db, user) before = await load_visible_project(db, vis, project_id) + _refuse_lifecycle_on_child( + values, getattr(before, "parent_project_id", None), + ) if not values: return row_to_dict(before, ProjectModel) after = await update_row(db, "pm_projects", project_id, values) changes = diff_changes(before, after, _TRACKED_PROJECT_FIELDS) if changes: - await record_activity( - db, activity_type="field_change", created_by=actor(user), - project_id=project_id, meta={"changes": changes}, + # The ONE field_change door (WS-27w) — none of the tracked project + # fields is FK-valued today, but the door is what keeps that claim + # checked rather than remembered, and a project-description edit + # session coalesces like a task's. + await record_field_change( + db, created_by=actor(user), project_id=project_id, + changes=changes, ) await db.commit() result = row_to_dict(after, ProjectModel) diff --git a/apps/services/gateway/gateway/routes/projects/watchers.py b/apps/services/gateway/gateway/routes/projects/watchers.py new file mode 100644 index 000000000..092cdfaf1 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/watchers.py @@ -0,0 +1,166 @@ +"""Projects · watchers — who follows a task (WS-27v). + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1, WS-27v. + + PUT /projects/tasks/{id}/watch → subscribe the caller + DELETE /projects/tasks/{id}/watch → unsubscribe the caller + GET /projects/tasks/{id}/watchers → {watchers, watching} + +A watcher row is an **intent to hear, never a right to see**. The fan-out in +``notifications.py`` still filters every recipient through +``resolve_visibility_for`` — Plane's membership-only check is the +counterexample, not the model — so a watcher who has lost the project's grant +keeps their row and stops hearing, exactly as they stop seeing. + +Auto-subscribe has ONE implementation: :func:`ensure_watchers`, called from +every write site that means "this person now cares about this task" — +commenting, editing, being assigned, being @mentioned, and the explicit watch +endpoint below. One helper rather than an INSERT per site, because the +casefolding (R10) and the agent fence are rules, and a rule copied five times +is a rule that drifts in one of them. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends +from gateway.routes.projects.core import ( + _get_db, + actor, + load_visible_task, + resolve_visibility, + router, +) +from sqlalchemy import text + + +def watchable(recipients: object) -> list[str]: + """The addresses a watcher row may carry: folded, deduped, human. + + Pure, and deliberately the same three fences as migration 165's CHECKs — + blanks, ``agent:`` and non-addresses are dropped here so a bad value + is silently skipped rather than surfacing as an IntegrityError 500 from a + comment that otherwise succeeded. Agents are excluded for WS-27j's rule 2: + they are handed work by the dispatch sink, and a subscription would feed an + inbox nobody ever opens. + """ + seen: dict[str, None] = {} + for raw in recipients or (): + clean = (raw or "").strip().lower() + if clean and "@" in clean and not clean.startswith("agent:"): + seen.setdefault(clean, None) + return list(seen) + + +async def ensure_watchers( + db: Any, task_id: str, recipients: object, *, by: str, +) -> list[str]: + """Idempotently subscribe people to a task; returns who was newly added. + + Idempotent by the UNIQUE(task_id, watcher) pair, not by a read-then-write: + two comments landing together must not race their way into a duplicate. + Does **not** commit — it runs inside the write that caused it, so an edit + and the subscription it implies land together or not at all. + + Deliberately no visibility check here: the write sites that call this have + already loaded the task through the caller's own visibility, and delivery + to the *watcher* is gated per event by ``resolve_visibility_for`` at + fan-out time — the one place that answer is allowed to live. + """ + added: list[str] = [] + for who in watchable(recipients): + row = (await db.execute( + text( + "INSERT INTO pm_task_watchers (task_id, watcher, created_by) " + "VALUES (CAST(:tid AS uuid), :who, :by) " + "ON CONFLICT (task_id, watcher) DO NOTHING RETURNING id" + ), + {"tid": task_id, "who": who, "by": by}, + )).fetchone() + if row is not None: + added.append(who) + return added + + +async def watchers_of(db: Any, task_id: str) -> list[str]: + """Everyone subscribed to one task, sorted.""" + rows = (await db.execute( + text( + "SELECT watcher FROM pm_task_watchers " + "WHERE task_id = CAST(:tid AS uuid) ORDER BY watcher" + ), + {"tid": task_id}, + )).fetchall() + return [r.watcher for r in rows if getattr(r, "watcher", None)] + + +@router.put("/tasks/{task_id}/watch") +async def watch_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Subscribe the caller. Idempotent — watching twice is watching. + + The identity is the session's (R3), never a body field: an endpoint that + accepted a ``watcher`` parameter would let anyone subscribe anyone else to + a stream of that task's titles. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + # R5: an invisible task is 404, never 403 — "not yours" and "no such + # task" must be one answer, or this endpoint becomes an oracle for + # which ids exist. + await load_visible_task(db, vis, task_id) + await ensure_watchers(db, task_id, [actor(user)], by=actor(user)) + await db.commit() + finally: + await db.close() + return {"task_id": task_id, "watching": True} + + +@router.delete("/tasks/{task_id}/watch") +async def unwatch_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Unsubscribe the caller. Idempotent — a row that is not there stays gone. + + Unwatching does not silence assignment: the audience is watchers ∪ + assignees, so somebody who holds the work keeps hearing about it. That is + the contract's own shape, not an oversight. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + await db.execute( + text( + "DELETE FROM pm_task_watchers " + "WHERE task_id = CAST(:tid AS uuid) AND watcher = :who" + ), + {"tid": task_id, "who": actor(user).lower()}, + ) + await db.commit() + finally: + await db.close() + return {"task_id": task_id, "watching": False} + + +@router.get("/tasks/{task_id}/watchers") +async def list_watchers( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Who watches this task, and whether the caller does — one read, so the + panel's toggle can render without a second round trip.""" + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + watchers = await watchers_of(db, task_id) + finally: + await db.close() + return { + "watchers": watchers, + "watching": actor(user).lower() in set(watchers), + } diff --git a/apps/services/gateway/gateway/routes/workflows/catalog.py b/apps/services/gateway/gateway/routes/workflows/catalog.py index 48909da32..e8882fef2 100644 --- a/apps/services/gateway/gateway/routes/workflows/catalog.py +++ b/apps/services/gateway/gateway/routes/workflows/catalog.py @@ -82,6 +82,18 @@ "no approval node required." ), }, + { + "type": "pm_lifecycle", + "category": "action", + "label": "Lifecycle sweep (Projects)", + "description": ( + "Archive long-closed tasks and close stale ones, per each root " + "project's lifecycle policy (archive/close months and timezone " + "live on the project — projects with no policy are untouched). " + "Config-free; pair it with a schedule trigger. An internal " + "write: no approval node required." + ), + }, { "type": "output", "category": "output", diff --git a/apps/services/gateway/gateway/routes/workflows/engine/graph.py b/apps/services/gateway/gateway/routes/workflows/engine/graph.py index d24022723..0671661c7 100644 --- a/apps/services/gateway/gateway/routes/workflows/engine/graph.py +++ b/apps/services/gateway/gateway/routes/workflows/engine/graph.py @@ -48,6 +48,11 @@ # Registry and carry the write-class approval gate, which a task moving # to Done must not need. "pm_task", + # WS-27z — the Projects lifecycle sweep. Internal like `pm_task`, and + # deliberately CONFIG-FREE: the whole policy (windows, timezone, + # which projects) lives in `pm_projects` columns, so there is nothing + # here for `_validate_node_config` to require. + "pm_lifecycle", } ) diff --git a/apps/services/gateway/gateway/routes/workflows/engine/handlers.py b/apps/services/gateway/gateway/routes/workflows/engine/handlers.py index 01e75e7ca..3d4f9b564 100644 --- a/apps/services/gateway/gateway/routes/workflows/engine/handlers.py +++ b/apps/services/gateway/gateway/routes/workflows/engine/handlers.py @@ -38,6 +38,9 @@ "trigger": 5.0, # An internal DB write — nothing here crosses the network. "pm_task": 30.0, + # The lifecycle sweep walks every root project with a policy (WS-27z). + # Internal DB work like pm_task, but N projects × M stale tasks of it. + "pm_lifecycle": 300.0, } DEFAULT_NODE_TIMEOUT = 60.0 @@ -71,6 +74,12 @@ class NodeServices: update_task: Callable[ [str, dict[str, Any]], Awaitable[dict[str, Any]] ] | None = None + #: ``run_lifecycle_sweep()`` → sweep counts (WS-27z). Takes NOTHING: the + #: whole policy — which projects, which windows, which timezone — lives in + #: the Projects app's own columns, so a workflow cannot be published that + #: sweeps differently from what each project's settings say. The wiring + #: binds the workflow's identity, same as ``update_task``. + run_lifecycle_sweep: Callable[[], Awaitable[dict[str, Any]]] | None = None actor: str = "workflow" #: Extra context merged into agent messages' metadata (reserved). context: dict[str, Any] = field(default_factory=dict) @@ -206,6 +215,14 @@ async def execute_node( if ntype == "pm_task": return await _execute_pm_task(config, state, services) + if ntype == "pm_lifecycle": + # Config-free by design (WS-27z): the node is an instruction to apply + # whatever each root project's stored policy says, so there is nothing + # to resolve and nothing a graph could override. + if services.run_lifecycle_sweep is None: + raise NodeExecutionError("the Projects app is not available") + return await services.run_lifecycle_sweep() + if ntype == "output": value = resolve_value(config.get("value"), state) return {"value": value} diff --git a/apps/services/gateway/gateway/routes/workflows/service.py b/apps/services/gateway/gateway/routes/workflows/service.py index 728d63acc..eead05dc3 100644 --- a/apps/services/gateway/gateway/routes/workflows/service.py +++ b/apps/services/gateway/gateway/routes/workflows/service.py @@ -181,6 +181,38 @@ async def _update(task_id: str, fields: dict[str, Any]) -> dict[str, Any]: return _update +def _pm_lifecycle_sweeper(workflow_id: str) -> Any: + """The Projects lifecycle sweep (WS-27z), identity bound in. + + Mirrors ``_pm_task_updater`` exactly — closure import so the workflows + package gains no import-time dependency on an app package, the workflow's + ``system:workflow:`` actor bound in, one commit around the whole + sweep. The seam takes no arguments because the policy lives in the + Projects app's own columns; the workflow supplies nothing but the + schedule and the node. + """ + + async def _sweep() -> dict[str, Any]: + try: + from gateway.routes.projects.automation import ( + run_lifecycle_sweep, + workflow_actor, + ) + except Exception as exc: # pragma: no cover — Projects ships with the gateway + raise NodeExecutionError("the Projects app is not available") from exc + db = await _get_db() + try: + result = await run_lifecycle_sweep( + db, actor=workflow_actor(workflow_id), + ) + await db.commit() + return result + finally: + await db.close() + + return _sweep + + def build_node_services(actor: str, workflow_id: str = "") -> NodeServices: return NodeServices( run_agent=_run_agent_node, @@ -188,6 +220,7 @@ def build_node_services(actor: str, workflow_id: str = "") -> NodeServices: get_module_code=_get_module_code, actor=actor, update_task=_pm_task_updater(workflow_id), + run_lifecycle_sweep=_pm_lifecycle_sweeper(workflow_id), ) diff --git a/infra/postgres/164_projects_intake.sql b/infra/postgres/164_projects_intake.sql new file mode 100644 index 000000000..c78163acb --- /dev/null +++ b/infra/postgres/164_projects_intake.sql @@ -0,0 +1,155 @@ +-- ============================================================================ +-- 164_projects_intake.sql — WS-27u · intake/triage: the front door. +-- +-- Spec: ai-company-brain/specs/project_management_app.md §9.1 (WS-27u), +-- plane_pm_research_2026-08.md §3 (P-1) for the rationale only — the +-- AGPL wall in that doc's header binds this file: the shape is +-- re-derived in this schema's own idiom, never translated. +-- +-- A captured task is REAL FROM BIRTH: it is an ordinary `pm_tasks` row, so a +-- comment, an assignee, an attachment or a link is legal on it from the first +-- second. What makes it "intake" is a WRAPPER row here plus a status whose +-- category is `triage` — and the one predicate in `routes/projects/core.py` +-- (`triage_exclusion_clause`) that keeps triage-category tasks out of every +-- board, list, calendar, timeline and search read unless `include_triage` is +-- passed. +-- +-- The wrapper is PROVENANCE, and provenance is permanent: accept, decline, +-- duplicate and snooze all UPDATE this row and none of them deletes it. Six +-- months later "where did this task come from, and who ruled on it" is a +-- point read, not an archaeology dig through `pm_activities`. +-- +-- Why a join table and not columns on `pm_tasks`: the overwhelming majority +-- of tasks are never captured — they are typed into a board by somebody who +-- has already decided the work is real — and four NULL columns on every one +-- of them would make the common row carry the rare row's bookkeeping. The +-- same trade `pm_task_personal` (147) made, for the same reason. +-- +-- Tenancy per D-MT-3, in the shape migration 161 established: the key is +-- carried on the row, filled and cross-checked by 161's +-- `pm_organization_from_parent` trigger (which this migration can attach +-- because 161 replays before it). `duplicate_of_task_id` gets its own +-- attachment for the reason `pm_task_links` has two: a row naming two tasks +-- is a row that could STRADDLE two organizations, and the second attachment +-- is what makes that impossible rather than merely unlikely. +-- +-- Idempotent per infra/postgres/README.md: IF NOT EXISTS everywhere, +-- CREATE OR REPLACE TRIGGER, and the category CHECK is widened through the +-- same find-by-shape DROP/ADD pair migration 150 used on `pm_activities.type` +-- — dropping an assumed constraint NAME is a silent no-op that leaves the +-- old, narrower CHECK in force beside the new one. +-- +-- Depends on: 146_projects.sql (pm_tasks, pm_task_statuses), +-- 161_projects_tenancy.sql (pm_organization_from_parent). +-- ============================================================================ + +BEGIN; + +-- ── The wrapper ───────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS pm_intake ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- UNIQUE: one front door per task. A task is either awaiting a ruling or + -- it has been ruled on; two wrappers would be two answers to which. + -- CASCADE with the task — the wrapper is provenance OF the task, and + -- provenance of a row that no longer exists points at nothing. + task_id UUID NOT NULL UNIQUE REFERENCES pm_tasks (id) ON DELETE CASCADE, + + -- The ruling. `pending` and `snoozed` are the queue states; the other + -- three are terminal and the row survives all of them. + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined', + 'duplicate', 'snoozed')), + + -- `snoozed` only: the instant the item REAPPEARS in the queue. The queue + -- read is `snoozed_until <= now()`, so nothing has to wake up and flip + -- the row back — a snooze that needed a worker would be a second + -- scheduler, which §5's non-goals forbid. + snoozed_until TIMESTAMPTZ, + + -- `duplicate` only: the task this one duplicates. SET NULL, not CASCADE — + -- deleting the original must not delete the record that this capture was + -- ruled a duplicate; the ruling stands even when its target is gone. + duplicate_of_task_id UUID REFERENCES pm_tasks (id) ON DELETE SET NULL, + + -- Where the capture came from and how to find it there: free text on + -- purpose ('email', 'slack', 'api', …) plus an opaque reference + -- (a message id, a permalink). Deliberately NOT the CHECK'd + -- `pm_tasks.source` vocabulary — routing rules (§6.5, out of scope here) + -- will speak in sources nobody has enumerated yet, and a CHECK would put + -- a migration between every new capture channel and its first row. + source TEXT, + source_ref TEXT, + + -- D-MT-3 — filled from the task by 161's trigger, attached below. + organization_id UUID REFERENCES organization (id) ON DELETE CASCADE, + + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The queue read: pending, plus snoozed rows whose time has come. +CREATE INDEX IF NOT EXISTS idx_pm_intake_status + ON pm_intake (status, snoozed_until); + +-- Fill from the task; refuse a wrapper whose org disagrees with its task's. +CREATE OR REPLACE TRIGGER trg_pm_intake_org_from_task + BEFORE INSERT OR UPDATE ON pm_intake + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +-- The straddle guard: when `duplicate_of_task_id` is set, its task must live +-- in the same organization. NULL passes through untouched (the function +-- returns NEW on a NULL parent id), so pending rows cost nothing. +CREATE OR REPLACE TRIGGER trg_pm_intake_org_from_duplicate + BEFORE INSERT OR UPDATE ON pm_intake + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'duplicate_of_task_id'); + +-- Belt and braces against a deploy that raced a capture: fill any stragglers +-- from the task before the constraint lands. No-op on an empty table and on +-- every replay (the trigger has already filled the column). +UPDATE pm_intake i + SET organization_id = t.organization_id + FROM pm_tasks t + WHERE t.id = i.task_id AND i.organization_id IS NULL; + +ALTER TABLE pm_intake ALTER COLUMN organization_id SET NOT NULL; + +-- ── `triage` joins the status-category vocabulary ─────────────────────────── +-- +-- The category is the machine-readable half of a status (146, §3.3), and +-- `triage` is the value that means "parked at the front door": the one +-- predicate in core.py keys off it, `load_default_status` never lands a +-- normally-created task in it (it is never seeded `is_default`), and +-- crossing OUT of it is what "accept" does. +-- +-- Dropped by SHAPE, not by assuming the constraint's default name — the +-- migration-150 lesson: dropping a name that does not exist is a silent +-- no-op, the ADD then succeeds under a second name, BOTH checks apply, and +-- `triage` is still refused while the migration reports success. + +DO $$ +DECLARE + cname text; +BEGIN + FOR cname IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'pm_task_statuses' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) LIKE '%in_progress%' + LOOP + EXECUTE format('ALTER TABLE pm_task_statuses DROP CONSTRAINT %I', cname); + END LOOP; + + ALTER TABLE pm_task_statuses + ADD CONSTRAINT pm_task_statuses_category_check + CHECK (category IN ('backlog', 'todo', 'in_progress', + 'done', 'cancelled', 'triage')); +END $$; + +COMMIT; diff --git a/infra/postgres/165_projects_watchers.sql b/infra/postgres/165_projects_watchers.sql new file mode 100644 index 000000000..11067e642 --- /dev/null +++ b/infra/postgres/165_projects_watchers.sql @@ -0,0 +1,85 @@ +-- 165_projects_watchers.sql — watchers, and mentions that behave (WS-27v). +-- +-- What: pm_task_watchers — one row per person following one task. +-- Why: spec §9.1 WS-27v (P-2, P-20 in plane_pm_research_2026-08.md §3.2): the +-- notification audience becomes watchers ∪ assignees, and commenting, +-- editing, assigning or being @mentioned subscribes you — the people who +-- touched a task keep hearing about it without opting in. +-- Depends on: 146_projects.sql (pm_tasks), 152 (pm_notifications, whose +-- recipient constraints this table mirrors), 161 (organization_id and +-- the pm_organization_from_parent trigger function). +-- +-- ⚠️ Shape re-derived in this repo's idiom, never translated: the research +-- doc's AGPL wall binds this file. One deliberate divergence is recorded +-- there and restated here: Plane gates delivery on project MEMBERSHIP; our +-- gate stays `resolve_visibility_for` — a watcher row is an intent to hear, +-- never a right to see, and the read path re-checks visibility per recipient. +-- +-- **A watcher is a HUMAN address, stored folded (R10).** Same two constraints +-- as pm_notifications' recipient, for the same reasons: an `agent:` row +-- would be an inbox nobody ever opens (agents are handed work by the WS-27f +-- dispatch sink), and every read compares folded, so a mixed-case row would be +-- a subscription that never fires. +-- +-- **The tenant key (D-MT-3).** Carried on the row like every pm_* table since +-- migration 161; filled and cross-checked against the task's by the same +-- pm_organization_from_parent trigger, so no INSERT site has to remember it. +-- +-- Idempotent per infra/postgres/README.md: IF NOT EXISTS everywhere, +-- CREATE OR REPLACE TRIGGER, and a seed whose ON CONFLICT makes the second +-- run a no-op. Pinned as TEXT by tests/unit/test_projects_watchers.py. + +BEGIN; + +CREATE TABLE IF NOT EXISTS pm_task_watchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- CASCADE: a watcher row on a deleted task is a subscription to nothing. + task_id UUID NOT NULL REFERENCES pm_tasks (id) ON DELETE CASCADE, + -- Lowercased email. Never `agent:` — see the header. + watcher TEXT NOT NULL, + organization_id UUID NOT NULL REFERENCES organization (id) ON DELETE CASCADE, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT pm_task_watchers_watcher_is_human + CHECK (watcher NOT LIKE 'agent:%' AND watcher <> ''), + CONSTRAINT pm_task_watchers_watcher_lowercased + CHECK (watcher = lower(watcher)), + -- One subscription per person per task. This is what makes every + -- auto-subscribe site idempotent: ensure_watchers ends in ON CONFLICT + -- DO NOTHING against this pair, so the fourth comment does not stack a + -- fourth row. + UNIQUE (task_id, watcher) +); + +-- The fan-out asks "who watches THIS task"; the UNIQUE above already indexes +-- (task_id, watcher) and serves that read, so no second task-leading index. +-- "What do I watch" gets its own when a surface actually asks it (WS-29b's +-- rule: an index earns its place from a query that filters on it). + +-- Fill-and-verify the tenant from the task's, exactly as 161 attaches the +-- other 17 pm_* tables. The function is 161's; only the attachment is new. +CREATE OR REPLACE TRIGGER trg_pm_task_watchers_org_from_task + BEFORE INSERT OR UPDATE ON pm_task_watchers + FOR EACH ROW EXECUTE FUNCTION + pm_organization_from_parent('pm_tasks', 'task_id'); + +-- ── Seed: task creators become watchers of their own tasks ────────────────── +-- +-- WS-27j's audience was assignees ∪ the task's author, derived per comment, +-- and its docstring promised "the derived audience is the one a watcher list +-- would be seeded with anyway, so nothing here has to be undone when one +-- arrives". This is that seeding: without it, every task created before this +-- migration would silently stop notifying its author the day the audience +-- switched to watchers ∪ assignees. Assignees need no seed — they stay in the +-- audience in their own right. +-- +-- Only human authors: `agent:` and non-address authors would fail the +-- CHECKs above, and an agent cannot receive a notification anyway (152). +INSERT INTO pm_task_watchers (task_id, watcher, organization_id, created_by) +SELECT t.id, lower(t.created_by), t.organization_id, 'system:migration_165' + FROM pm_tasks t + WHERE t.created_by LIKE '%@%' + AND t.created_by NOT LIKE 'agent:%' +ON CONFLICT (task_id, watcher) DO NOTHING; + +COMMIT; diff --git a/infra/postgres/166_projects_lifecycle.sql b/infra/postgres/166_projects_lifecycle.sql new file mode 100644 index 000000000..c75d1ce3a --- /dev/null +++ b/infra/postgres/166_projects_lifecycle.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- 166_projects_lifecycle.sql — WS-27z · lifecycle policy: auto-archive and +-- auto-close, as three ROOT-project columns. +-- +-- Spec: ai-company-brain/specs/project_management_app.md §9.1 (WS-27z), +-- plane_pm_research_2026-08.md §3 (P-4, and the P-28 timezone part) for +-- the rationale only — the AGPL wall in that doc's header binds this +-- file: the shape is re-derived in this schema's own idiom, never +-- translated from Plane. +-- +-- What: `pm_projects` gains `archive_after_months`, `close_after_months` +-- (nullable INT, NULL = the policy is OFF — the default, because a +-- sweeper that touches real data must be opted into per project) and a +-- `timezone` (an IANA name, default 'UTC') so "a month untouched" is +-- measured against a midnight the project's owner would recognise +-- rather than against Greenwich. +-- +-- Why columns and not a workflow config: D6 — `/workflows` is the only +-- engine, so the SWEEP runs as a scheduled workflow (never a PM-app cron), +-- but the POLICY is per-project data the Projects app owns and edits. The +-- workflow supplies nothing but the trigger and the sweep node; everything +-- it does is read from these columns at run time. +-- +-- ⚠️ ROOT-project settings, like every other piece of per-project +-- configuration here (statuses, types, custom fields, tags are all keyed to +-- the ROOT and the subtree inherits). The columns exist on every +-- `pm_projects` row because the table is one self-FK tree, but the sweep +-- reads them from ROOT rows only and acts on `pm_tasks.root_project_id` — +-- so the root's policy governs its whole subtree, and a value written on a +-- child row is inert. The API refuses to write one (422), keeping the inert +-- case unreachable rather than merely documented. +-- +-- The months CHECKs live on the columns: zero or negative months would make +-- the cutoff "now or the future" and sweep live work. NULL passes any CHECK, +-- which is exactly the OFF state. `timezone` cannot be CHECK-validated +-- against the IANA database from SQL; the API validates it the same way the +-- workflows app validates a schedule trigger's timezone (zoneinfo), and the +-- sweep falls back to UTC rather than skipping a project if a bad name ever +-- reaches a row. +-- +-- Idempotent per infra/postgres/README.md: ADD COLUMN IF NOT EXISTS +-- throughout (the column-level CHECKs ride inside the guarded ADDs, so a +-- replay never re-adds a constraint). Pinned as TEXT by +-- tests/unit/test_projects_lifecycle.py. +-- +-- Depends on: 146_projects.sql (pm_projects). +-- ============================================================================ + +BEGIN; + +-- Archive: tasks whose status CATEGORY is done/cancelled and untouched +-- (updated_at) for this many months leave every default surface. NULL = off. +ALTER TABLE pm_projects + ADD COLUMN IF NOT EXISTS archive_after_months INT + CHECK (archive_after_months > 0); + +-- Close: OPEN tasks untouched for this many months move to the project's +-- closing status (its `cancelled`-category lane, else its `done` one) via the +-- ordinary status transition — never a column write. Triage-category tasks +-- (WS-27u) are exempt: the front-door queue is a place work WAITS. NULL = off. +ALTER TABLE pm_projects + ADD COLUMN IF NOT EXISTS close_after_months INT + CHECK (close_after_months > 0); + +-- The midnight "a month untouched" is measured against (P-28). An IANA name; +-- API-validated, see the header. +ALTER TABLE pm_projects + ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'; + +COMMIT; diff --git a/project-docs/HANDOVER.md b/project-docs/HANDOVER.md index 993294fd5..d7f984d73 100644 --- a/project-docs/HANDOVER.md +++ b/project-docs/HANDOVER.md @@ -17,6 +17,34 @@ ## 1. Where the branch is +> ### ⚠️ 2026-08-10 — #399 MERGED; branch restarted from `main`; WS-27u–z ALL BUILT +> +> PR **#399 merged to main**. Per the merged-PR rule the branch was restarted from +> `origin/main` (same name, fresh history) — everything below this box describing "open PR +> #399" is now historical record. On the restarted branch, six parallel agents built the +> whole §9.1 beyond-parity queue **plus the owner-directed Tasks↔Projects continuity +> backport**; all merged, verified (5789 backend / 1278 frontend / tsc / ruff / theme +> conformance green) and pushed. **No PR opened yet** — not asked for. +> +> **New migrations awaiting the real box** (check `schema_migrations`, apply in order — +> they follow 160–162 from §1.1): **164** `projects_intake` (pm_intake + `triage` +> category), **165** `projects_watchers` (pm_task_watchers; seeds task authors as +> watchers), **166** `projects_lifecycle` (per-root archive/close months + timezone, +> default off). +> +> **New owner steps** (beyond §4): to activate WS-27z, author a workflow in `/workflows` +> — schedule trigger + one config-free **`pm_lifecycle`** node — and publish it (the +> canvas palette predates the Projects nodes; author via the workflows API/copilot, the +> `pm_task` precedent). Then set months/timezone per root project via the Lifecycle +> dialog. Everything defaults NULL = off. +> +> **Continuity gaps that remain** (from the backport agent's audit, for whoever continues): +> Tasks' modal select-mode vs Projects' shift-range selection (biggest; needs an anchor in +> `taskStore`); board chrome (Tasks: accent caps + drop-gap reorder; Projects: swimlanes + +> append-on-drop); Tasks' flat lists (Done/Waiting/Someday/Archive), `WaitingForView` and +> the Inbox's own j/k idiom have neither shared cursor nor quick-add; calendar asymmetry +> (known, deliberately out of scope — Tasks has a 10-file module, Projects one view). + **Tree clean, everything pushed.** Open PR **#399**. > ### ⚠️ 2026-08-09 — `main` moved, and this branch has been merged with it diff --git a/project-docs/specs/project_management_app.md b/project-docs/specs/project_management_app.md index fa530ad36..425cccf64 100644 --- a/project-docs/specs/project_management_app.md +++ b/project-docs/specs/project_management_app.md @@ -1085,6 +1085,32 @@ the standing protocol: hermetic tests against the fake, mutation-tested guards, Postgres run, and R1 (migration numbers resolved at build time — every number below is a description, not an assignment). +> **✅ ALL SIX BUILT 2026-08-10** on the restarted branch (after #399 merged), six parallel +> agents + two integration merges; full suites green (5789 backend / 1278 frontend). +> Build-time facts that differ from or sharpen the ticket text: +> - Migration numbers resolved as **164** (intake), **165** (watchers), **166** (lifecycle); +> `main` had taken 163 in the interim. WS-27w needed **no** migration. +> - **WS-27w item 1**: no archive endpoint existed at all — `archived_at` had no writer. The +> ticket's guard therefore shipped as new `POST …/archive` + `/unarchive` endpoints with +> the 422 guard built in (guard phrased as `category not in CLOSING_CATEGORIES`, so +> `triage` is refused without depending on WS-27u). +> - **WS-27v**: migration 165 seeds each task's author as a watcher (the pre-watchers +> audience included authors; the switch must not silently unsubscribe them), and only +> *delivered* mentions auto-subscribe. +> - **WS-27z**: `/workflows` workflows are DB rows, not files — so the deliverable is the +> sweep (`automation.run_lifecycle_sweep`), a config-free **`pm_lifecycle`** engine node +> mirroring `pm_task`'s wiring, and `automation: true` on `record_activity`. Authoring + +> publishing the schedule-triggered workflow is an **owner step on the live box** +> (HANDOVER §1.1). "Default closing status" does not exist in the schema; the sweep's +> recorded model is first `cancelled` lane by position, else first `done`. Root's policy +> governs the subtree; the API 422s a policy write on a child. +> - **Continuity backport (owner directive, same day)**: the WS-27y machinery was promoted +> to shared code (`src/lib/cursor.ts`, `src/components/QuickAdd.tsx` + `useFlash`) with +> re-export shims, the Tasks app's cards now draw the WS-27s chip vocabulary, and its +> board/list gained the cursor, group-context quick-add, and drop-refusal/flash grammar. +> Remaining divergences are recorded in HANDOVER (Tasks' modal selection vs Projects' +> shift-range is the biggest). + **WS-27u — intake/triage: the front door.** 🟢 AGENT-SAFE *(P-1)*. A captured task is real from birth, parked out of sight until a human rules on it. Done when: (1) a migration adds a `pm_intake` join table (`task_id` unique, `status ∈ diff --git a/project-docs/work_plan.md b/project-docs/work_plan.md index 994cce439..f11644ec2 100644 --- a/project-docs/work_plan.md +++ b/project-docs/work_plan.md @@ -187,7 +187,7 @@ owning specs are the archive; this file owns ordering, gates and states only. | WS-21 | **Calendar F2/F3** | 🟡 partial | `calendar_focus_os.md` §9 (+§5) + `calendar_timeboxing.md` §13 · board record 2026-08-09 | P3 roll-over + ideal-week + packer-breaks all shipped (struck from scope 2026-08-03). `gtd_time_blocks` is **four slices S1–S4** — the "one non-breaking PR" claim was false (17 TS files + 3 gateway modules + skill + agent). Focus Shield is AGENT-SAFE (needs a design, not a credential). Owns Horizons (§4) — DO-NOT-DISPATCH, no acceptance. 🔴 external-sync OAuth credentials (§6) · shared nudge-send gate (§6). Never `pytest tests/unit -k calendar` (collection hangs). (2026-08-03) | | WS-22 | **draw.io** | ⏸ PARKED | `drawio_integration.md` | **PARKED BY OWNER 2026-08-10 (D25.7)** — no agent time until a real need (proposal diagrams, KB visuals) pulls it back. The spec's acceptance structure keeps; anchors need re-verification at un-park. | | WS-26 | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | ✅ a–g · D5 PR open | `specs/crm_app.md` · board record 2026-08-09 | a + b + c + d (read · email · write) **merged + deployed** (d-write log-verified via deploy `31217978773`, 2026-08-08); f + g **merged to main** (#391, #397 — the old "on branch, NOT run against prod" wording is struck; f's stage repair still needs its 🔴 `?apply=true` run, §6 WS-26 (d)). **D5 d-autolead MERGED 2026-08-09 (#403; migration renumbered 158→163 at merge)** — remaining: 🔴 `CRM_AUTO_LEAD` flip (§6 WS-26 (b); clamp-anchor design, never reset-to-now). Zoho sync loop **ENABLED by the owner 2026-08-06** (§6 WS-26 (a)) — every "ships OFF / never run" sentence about it is struck. Next: **h** stage entry-requirements + rot badges (after f2) · **i** merge/bulk/CSV/saved-views — spec-thin, audit-narrow first · **e** cutover + retirement 🔴 (§6 WS-26 (c)). ⚠️ D15 coda: built single-Zoho-tenant by design; per-org credentials (migration 158) + per-org sync flags arrive with MT-1/MT-2, and D-CRM-3's org-wide read becomes org-scoped **by RLS**, not by hand-written predicates. (2026-08-08) | -| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–t merged (o–t via #399, 2026-08-09) · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07) | +| WS-27 | **Projects app — native PM + ClickUp retirement** *(minted 2026-08-05)* | ✅ a–t merged · **u–z on PR #408** · c/g/h gated | `specs/project_management_app.md` · board record 2026-08-09 | a b d e f i j k l m n **merged to main** (#390, #393, #394, #398 + fixes — the board's "BUILT on branch" wording is struck). ~~Open defect: **§11.12** — WS-27j's `notifications.deliverable` probes `project_clause`~~ ✅ **FIXED on #399** (assignees without a project grant were judged undeliverable, so assignment notified nobody). 🟡 **c** two-way sync waits on WS-1's BO-1a + BO-1b; 🔴 push enable (§6 WS-27 (b)) · 🔴 **g** cutover + retirement incl. the root-`AGENTS.md` constraint-8 amendment — ships in the g PR, never before (§6 WS-27 (c)) · **h** `gtd_items` retirement after e; the data move is 🔴. ~~Remaining letters: recurring, dependency UI, calendar view, search.~~ ✅ **the §11.2 ClickUp-parity backlog is CLOSED** — o recurrence · p dependencies+subtasks · q calendar · r ⌘K search · s shared task card · t timeline, all on **PR #399** with D-PM-11/D-PM-12 recorded. **Second reference studied 2026-08-09: `makeplane/plane` v1.4.1 (⚠️ AGPL-3.0 — patterns only, never code)** → `specs/plane_pm_research_2026-08.md` + spec §11.19: 12 shipped decisions validated, beyond-parity queue P-1…P-31 minted → **minted as dispatchable tickets WS-27u–z (spec §9.1)**: u intake/triage · v watchers+mention-diff · w read-path/history hardening · x spreadsheet+shown-fields · y board upgrades · z lifecycle policy (🟡 per-project, default off) + a deferred small basket, 2 owner questions ANSWERED same day → **D-PM-13** (project docs live in the knowledge base — creator-owned, grant-shared; PM links, never owns) · **D-PM-14** (public boards deferred). ✅ **WS-27u–z ALL BUILT 2026-08-10 on the restarted branch** (#399 merged; branch restarted from main per the merged-PR rule) — migrations **164** intake · **165** watchers · **166** lifecycle; plus the **Tasks↔Projects continuity backport** (shared chips/cursor/QuickAdd/flash promoted to `src/lib`+`src/components`, both apps consume one implementation; remaining gaps recorded in HANDOVER). z's sweeper is wired as a `pm_lifecycle` workflow node — the scheduled workflow itself is an owner authoring step on the live box (workflows are DB rows, never files). ⚠️ granting `feature:projects`/`data:org:read` is §6 WS-27 (d) — D14's zero-consumer measurement is retired by this row. (2026-08-07 · updated 2026-08-10) | | WS-28 | **People Center — directory, org chart, assignment seam** *(minted 2026-08-06)* | ✅ a+b+b-write | `specs/people_center_app.md` · board record 2026-08-09 | a (key shape, mig 148 + quarantine table) · b (directory + person page, mig 149, five-place registration) · b-write (create/edit UI restored; found three ways mig 148 had broken the write routes) — built 2026-08-06/07; **closes WS-13's directory item**. 🟢 c org chart · d capability search (**ranking EVAL-LOCKED**) · e Projects seams; 🔴 f seats/roles writes (§6 WS-24 (d) analogue). ⚠️ `schema.generated.sql` regeneration is **due**: stale since ~migration 113, and 148 reached prod ~2026-08-07 (after the #384 cast fix). (2026-08-07) | --- diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 3545a9a1a..f2ab3933d 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -74,6 +74,11 @@ _LOWER_EQ = re.compile(r"lower\((?:\w+\.)?(\w+)\)\s*=\s*:(\w+)", re.I) #: `` = :param`` — never inside a lower() or a CAST. _PLAIN_EQ = re.compile(r"(? = ANY(CAST(:param AS uuid[]))`` — a bounded id-set membership test +#: (WS-27w's relatives walk and picker exclusion). +_ANY_UUID = re.compile( + r"(?:\w+\.)?(\w+)\s*=\s*ANY\(CAST\(:(\w+)\s+AS\s+uuid\[\]\)\)", re.I +) #: `` = 'literal'`` _LITERAL_EQ = re.compile(r"\b(?:\w+\.)?(\w+)\s*=\s*'([^']*)'") #: `` IS [NOT] NULL`` @@ -84,6 +89,11 @@ #: WS-27q, which meant every `overdue` test was really only asserting the #: status half and would have passed with the date comparison deleted. _NOW_LT = re.compile(r"\b(?:\w+\.)?(\w+)\s*<\s*now\(\)", re.I) +#: `` < :param`` — a bound strict-less-than (WS-27z's untouched-since +#: cutoff, and the `due_before` filter). The column must be a bare word right +#: before the `<`, so `coalesce(…) < :window_to` (handled by `_WINDOW_CMP`) +#: never matches, and `<=` never matches (`=` is not `:`). +_BOUND_LT = re.compile(r"\b(?:\w+\.)?(\w+)\s*<\s*:(\w+)\b") #: WS-27q's calendar window: ``coalesce(, ) < :window_to``. The captured #: expression is what says WHICH interval endpoint the comparison is about, so #: the mirror reads the SQL's coalesce order rather than assuming one. @@ -177,11 +187,15 @@ "pm_tasks": ("pm_projects", "project_id"), "pm_activities": ("pm_tasks", "task_id"), "pm_task_assignees": ("pm_tasks", "task_id"), + "pm_task_watchers": ("pm_tasks", "task_id"), "pm_task_links": ("pm_tasks", "source_task_id"), "pm_task_attachments": ("pm_tasks", "task_id"), "pm_task_personal": ("pm_tasks", "task_id"), "pm_notifications": ("pm_tasks", "task_id"), "pm_view_task_positions": ("pm_views", "view_id"), + # WS-27u. The wrapper's SECOND attachment (`duplicate_of_task_id`) is a + # refuse-a-straddle constraint, which is this fake's stated blind spot. + "pm_intake": ("pm_tasks", "task_id"), } @@ -341,6 +355,9 @@ def _now() -> datetime: "position": None, "archived_at": None, "clickup_id": None, "clickup_kind": None, "task_prefix": None, "lead": None, "description": None, + # WS-27z — migration 166's lifecycle policy. NULL = off, the default. + "archive_after_months": None, "close_after_months": None, + "timezone": "UTC", }, "pm_project_grants": {}, "pm_task_statuses": {"color": "gray", "position": 0, "is_default": False}, @@ -355,6 +372,7 @@ def _now() -> datetime: "clickup_synced_at": None, "task_number": 1, }, "pm_task_assignees": {}, + "pm_task_watchers": {"created_by": None}, "pm_task_links": {}, "pm_activities": {"body": None, "meta": None, "task_id": None, "project_id": None, "deleted_at": None}, @@ -367,11 +385,16 @@ def _now() -> datetime: "energy": None, "time_estimate_mins": None, "is_two_minute": False, "defer_until": None, "clarified_at": None, }, + # WS-27u — migration 164's defaults, mirrored. + "pm_intake": { + "status": "pending", "snoozed_until": None, + "duplicate_of_task_id": None, "source": None, "source_ref": None, + }, } _TIMESTAMPED = { "pm_projects", "pm_tasks", "pm_task_statuses", "pm_task_types", - "pm_activities", "pm_views", + "pm_activities", "pm_views", "pm_intake", } @@ -568,6 +591,41 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: # be mistaken for a column predicate and drop every row. if "END AS rank" in statement: return _Result(self._search_hits(statement, args)) + # WS-27v's audience: watchers ∪ assignees, a UNION the generic WHERE + # reader cannot parse (it would filter watcher rows by the SECOND + # SELECT's predicates too). Each arm is honoured ONLY when the + # statement carries it — the `_select` convention — so an audience that + # loses either arm loses it here as well and the fan-out test goes red. + if "AS who" in statement and "pm_task_watchers" in statement: + wanted = str(args.get("tid")) + people: set[str] = set() + if "watcher AS who" in statement: + people |= { + str(r.get("watcher")) + for r in self.rows("pm_task_watchers") + if str(r.get("task_id")) == wanted + } + if "assignee AS who" in statement: + people |= { + str(r.get("assignee")) + for r in self.rows("pm_task_assignees") + if str(r.get("task_id")) == wanted + } + return _Result([SimpleNamespace(who=w) for w in sorted(people)]) + # WS-27u's intake queue: a join the generic reader cannot parse, and an + # OR over the wrapper's two queue states it must not try to. Answered + # explicitly, every arm keyed off the statement like the inbox's. + if "JOIN pm_intake i" in statement: + found = self._intake_queue(statement, args) + if re.search(r"SELECT\s+count\(\*\)", statement, re.I): + return _Result([], scalar=len(found)) + offset = _OFFSET_RE.search(statement) + if offset: + found = found[int(args.get(offset.group(1), 0)):] + limit = _LIMIT_RE.search(statement) + if limit: + found = found[: int(args.get(limit.group(1), len(found)))] + return _Result(found) head = statement.split(None, 1)[0].upper() table = self._table(statement) if head == "INSERT": @@ -657,11 +715,31 @@ def _search_hits(self, statement: str, args: dict) -> list[Any]: tenanted = bool(_ROW_TENANT.search(_CLOSURE_BODY.sub("", statement))) org = str(args.get("vis_org")) tenant_only = bool(_TENANT_PROJECTS.search(statement)) + # WS-27w's picker exclusion — applied ONLY when the statement carries + # the clause, so a search that drops it stops excluding here too and + # the four-class test goes red. + excluded = ( + {str(i) for i in (args.get("excluded") or [])} + if "NOT (t.id = ANY" in statement else set() + ) + # WS-27u — search excludes the intake queue unless `include_triage` + # formatted the predicate away. Keyed on the clause's own alias. + parked = ( + { + str(s["id"]) for s in self.rows("pm_task_statuses") + if s.get("category") == "triage" + } + if "s_triage" in statement else None + ) found: list[Any] = [] for task in self.rows("pm_tasks"): + if str(task.get("id")) in excluded: + continue if tenanted and str(task.get("organization_id")) != org: continue + if parked is not None and str(task.get("status_id")) in parked: + continue if tenant_only and str(task.get("project_id")) not in self.tenant_project_ids(org): continue if scoped and str(task.get("project_id")) not in visible: @@ -732,6 +810,84 @@ def _window_links(self, statement: str, args: dict) -> list[Any]: )) return sorted(out, key=lambda r: str(r.id)) + def _intake_queue(self, statement: str, args: dict) -> list[Any]: + """WS-27u's front-door queue: pending wrappers, plus snoozes past due. + + Every arm is applied ONLY when the statement carries it — the module + docstring's rule, so a route that drops its visibility clause or its + queue-state arm stops being filtered here and its test goes red. The + reappearance is mirrored as the SQL means it: a comparison against + now(), never a status flip, because nothing in the system wakes a + snoozed row — it reappears by being read. + """ + wrappers = {str(w.get("task_id")): w for w in self.rows("pm_intake")} + scoped = "pm_project_grants" in statement + me = str(args.get("vis_email") or "").lower() + visible = self.visible_project_ids( + me, list(args.get("vis_groups") or []), + organization_id=( + str(args.get("vis_org")) + if _CLOSURE_IS_TENANTED.search(statement) else None + ), + descendant_organization_id=( + str(args.get("vis_org")) + if _DESCENT_IS_TENANTED.search(statement) else None + ), + ) + assignee_escape = ( + "pm_task_assignees" in statement and "vis_email" in statement + ) + tenanted = bool(_ROW_TENANT.search(_CLOSURE_BODY.sub("", statement))) + tenant_only = bool(_TENANT_PROJECTS.search(statement)) + org = str(args.get("vis_org")) + skips_archived = "t.archived_at IS NULL" in statement + wants_pending = "i.status = 'pending'" in statement + reappears = "i.snoozed_until <= now()" in statement + subtree = ( + self._subtree_ids(str(args.get("pid"))) + if "RECURSIVE sub" in statement else None + ) + + out: list[Any] = [] + for task in self.rows("pm_tasks"): + wrap = wrappers.get(str(task.get("id"))) + if wrap is None: + continue + if tenanted and str(task.get("organization_id")) != org: + continue + if tenant_only and ( + str(task.get("project_id")) not in self.tenant_project_ids(org) + ): + continue + if scoped and str(task.get("project_id")) not in visible and not ( + assignee_escape and me in self._assignees_of(task.get("id")) + ): + continue + if skips_archived and task.get("archived_at") is not None: + continue + if subtree is not None and str(task.get("project_id")) not in subtree: + continue + state = wrap.get("status") + queued = (wants_pending and state == "pending") or ( + reappears and state == "snoozed" + and wrap.get("snoozed_until") is not None + and _as_datetime(wrap["snoozed_until"]) <= _now() + ) + if not queued: + continue + out.append(SimpleNamespace( + **task, + intake_id=wrap.get("id"), + intake_status=state, + intake_snoozed_until=wrap.get("snoozed_until"), + intake_source=wrap.get("source"), + intake_source_ref=wrap.get("source_ref"), + intake_duplicate_of_task_id=wrap.get("duplicate_of_task_id"), + intake_created_at=wrap.get("created_at"), + )) + out.sort(key=lambda r: (_sortable(r.intake_created_at), str(r.id))) + return out + def _blocker_counts(self, statement: str, args: dict) -> list[Any]: """``{blocked, blockers}`` counting only blockers that are still OPEN. @@ -1162,8 +1318,26 @@ def _apply_subqueries( who = str(args.get(bound[-1]) or "").lower() rows = [r for r in rows if who in self._assignees_of(r.get("id"))] - # NOT EXISTS over pm_task_statuses — /assigned-to-me hiding closed work. - if any("pm_task_statuses" in b and "category" in b for b in blocks): + # WS-27u — the default-list exclusion: `NOT EXISTS` over a + # triage-category status (`core.triage_exclusion_clause`). Applied only + # when the statement carries the `s_triage` alias, so a surface that + # drops the predicate — or silently drops `include_triage` — leaks the + # queue here too and the exclusion tests go red. + if any("s_triage" in b for b in blocks): + seen = True + parked = { + str(s["id"]) for s in self.rows("pm_task_statuses") + if s.get("category") == "triage" + } + rows = [r for r in rows if str(r.get("status_id")) not in parked] + + # NOT EXISTS over pm_task_statuses — /assigned-to-me hiding closed + # work. The triage predicate above also names both fingerprints, so it + # is excluded here by its own alias. + if any( + "pm_task_statuses" in b and "category" in b and "s_triage" not in b + for b in blocks + ): seen = True closed = { str(s["id"]) for s in self.rows("pm_task_statuses") @@ -1184,6 +1358,10 @@ def _apply_columns( seen = True want = str(args.get(param) or "").lower() rows = [r for r in rows if str(r.get(column) or "").lower() == want] + for column, param in _ANY_UUID.findall(top): + seen = True + wanted_ids = {str(v) for v in (args.get(param) or [])} + rows = [r for r in rows if str(r.get(column)) in wanted_ids] for column, param in _PLAIN_EQ.findall(top): seen = True rows = [r for r in rows if r.get(column) == args.get(param)] @@ -1210,6 +1388,26 @@ def _apply_columns( r for r in rows if r.get(column) is not None and _as_datetime(r[column]) < _now() ] + # WS-27z — ` < :param` with SQL's NULL semantics (a NULL column + # never matches). Datetimes compare as instants however they were + # stored; anything else falls back to `_sortable`'s total order. + for column, param in _BOUND_LT.findall(top): + if param not in args: + continue + seen = True + edge = args[param] + if isinstance(edge, datetime): + rows = [ + r for r in rows + if r.get(column) is not None + and _as_datetime(r[column]) < edge + ] + else: + rows = [ + r for r in rows + if r.get(column) is not None + and _sortable(r.get(column)) < _sortable(edge) + ] # WS-27q's calendar window. Applied ONLY when the statement carries the # bound, and each comparison is evaluated against the interval endpoint # the SQL's own `coalesce` order names — so swapping that order, which @@ -1232,6 +1430,32 @@ def _apply_columns( return rows, seen def _ordered(self, statement: str, rows: list[dict]) -> list[dict]: + # WS-27w's semantic status sort: category rank, then lane position, + # then the `(created_at, id)` tiebreaker. The RANK ORDER is read off + # the statement's own ARRAY literal rather than assumed, so a route + # that reordered — or alphabetised — the vocabulary changes this + # mirror's answer instead of being invisible to it. + if "array_position" in statement and "s.category" in statement: + literal = re.search(r"ARRAY\[([^\]]*)\]", statement) + rank = [ + v.strip().strip("'") + for v in (literal.group(1).split(",") if literal else []) + ] + reverse = bool(re.search(r"t\.id\s+DESC", statement, re.I)) + uses_position = "s.position" in statement + statuses = {str(s["id"]): s for s in self.rows("pm_task_statuses")} + + def status_key(row: dict) -> tuple: + status = statuses.get(str(row.get("status_id")), {}) + category = str(status.get("category") or "") + return ( + rank.index(category) if category in rank else len(rank), + _sortable(status.get("position")) if uses_position else 0, + _sortable(row.get("created_at")), + str(row.get("id")), + ) + + return sorted(rows, key=status_key, reverse=reverse) order = _ORDER_RE.search(statement) if order is None: return rows diff --git a/tests/unit/test_admin_tenancy.py b/tests/unit/test_admin_tenancy.py index eb6b350d6..d5bde210b 100644 --- a/tests/unit/test_admin_tenancy.py +++ b/tests/unit/test_admin_tenancy.py @@ -1,7 +1,7 @@ """The admin plane · the TENANT boundary — what one organization's admin cannot reach in another (WS-29e). -Spec: ``project-docs/specs/multi_tenancy_leak_audit.md`` S1-1 and +Spec: ``ai-company-brain/specs/multi_tenancy_leak_audit.md`` S1-1 and ``multi_tenancy.md`` §3 (D-MT-1 (a)). ``test_projects_tenancy.py`` fences the READ path: one company's portfolio diff --git a/tests/unit/test_projects_filters.py b/tests/unit/test_projects_filters.py index efb84b3e7..5a72c9d3f 100644 --- a/tests/unit/test_projects_filters.py +++ b/tests/unit/test_projects_filters.py @@ -56,15 +56,42 @@ def bound(**kwargs) -> dict: # ── The vocabulary matches the schema ─────────────────────────────────────── +def migrated_status_categories() -> set[str]: + """The category vocabulary as the DATABASE will enforce it. + + Assembled from every migration that constrains `pm_task_statuses.category`, + taking the LAST one in file order — 146 creates the CHECK inline and 164 + (WS-27u) replaces it wholesale to admit `triage`, so the newest definition + is the one that survives a full replay. The same aggregation + `test_projects_migration._activity_check_values` does for the activity + vocabulary, and for the same reason: a mirror pinned to the file that + CREATED the check goes quietly stale the day a later file widens it. + + Scoped to files that name `pm_task_statuses`, because `feature_catalog` + (130/140) constrains a `category` column of its own. + """ + latest: str | None = None + for path in sorted((REPO / "infra/postgres").glob("*.sql")): + if path.name == "schema.generated.sql": + continue + text = "\n".join( + re.sub(r"--.*$", "", line) + for line in path.read_text(encoding="utf-8").splitlines() + ) + if "pm_task_statuses" not in text: + continue + for match in re.finditer( + r"CHECK\s*\(\s*category\s+IN\s*\((.*?)\)\s*\)", text, re.S | re.I, + ): + latest = match.group(1) + assert latest is not None, "no migration constrains pm_task_statuses.category" + return set(re.findall(r"'([a-z_]+)'", latest)) + + def test_the_categories_are_the_ones_the_database_has(): - """Read from the migration, not restated. A filter vocabulary that drifts + """Read from the migrations, not restated. A filter vocabulary that drifts from the CHECK is a filter that silently matches nothing.""" - text = (REPO / "infra/postgres/146_projects.sql").read_text(encoding="utf-8") - text = "\n".join(re.sub(r"--.*$", "", line) for line in text.splitlines()) - match = re.search(r"category\s+TEXT\s+NOT\s+NULL[^,]*?CHECK\s*\(\s*category\s+IN\s*\((.*?)\)\)", - text, re.S | re.I) - assert match, "146 no longer constrains pm_task_statuses.category" - assert set(re.findall(r"'([a-z_]+)'", match.group(1))) == set(STATUS_CATEGORIES) + assert migrated_status_categories() == set(STATUS_CATEGORIES) def test_closed_means_done_or_cancelled(): @@ -287,6 +314,91 @@ def test_every_advertised_grouping_survives_normalisation(group_by): assert normalise_view_config({"group_by": group_by})["group_by"] == group_by +def test_lane_state_survives_normalisation(): + """WS-27y — the sub-axis and its lane state ride the view config. The + server must not strip them, or every save round-trip silently flattens + the board back to a lane-less one.""" + got = normalise_view_config({ + "group_by": "status", + "sub_group_by": "assignee", + "collapsed_lanes": ["a@x.io", 7, "b@x.io"], + "show_empty_lanes": True, + }) + assert got["sub_group_by"] == "assignee" + assert got["collapsed_lanes"] == ["a@x.io", "b@x.io"] + assert got["show_empty_lanes"] is True + + +def test_a_sub_axis_equal_to_the_main_axis_is_dropped_with_its_lane_state(): + """Mirrors grouping.ts fromConfig: laning a board by its own columns is + nonsense a hand-edited config could still say.""" + got = normalise_view_config({ + "group_by": "status", + "sub_group_by": "status", + "collapsed_lanes": ["todo"], + "show_empty_lanes": True, + }) + assert "sub_group_by" not in got + assert "collapsed_lanes" not in got + assert "show_empty_lanes" not in got + + +def test_a_lane_less_view_stores_no_lane_keys_at_all(): + """A view saved before lanes existed and one saved after with no lanes + must stay byte-identical, so nothing bumps updated_at on a no-op save.""" + assert normalise_view_config({"group_by": "status"}) == { + "filters": {}, "group_by": "status", + } + + +def test_shown_fields_survive_normalisation_with_junk_dropped(): + """WS-27x — the shown-fields set rides the view config. Unknown keys and + non-strings are hand-edits (or a newer client's vocabulary) and are + dropped; the rest must come back intact or every save round-trip would + strip somebody's column choices.""" + got = normalise_view_config({ + "group_by": "status", + "shown_fields": ["status", "phase", 7, None, "due_at", "assignees"], + }) + assert got["shown_fields"] == ["status", "due_at", "assignees"] + + +def test_every_advertised_shown_field_survives_normalisation(): + from gateway.routes.projects.filters import SHOWN_FIELDS + + got = normalise_view_config({"shown_fields": list(SHOWN_FIELDS)}) + assert got["shown_fields"] == list(SHOWN_FIELDS) + + +def test_custom_field_keys_pass_by_shape_and_a_bare_prefix_does_not(): + """`custom.` names a project field the pure normaliser cannot look + up, so it is checked by shape — and `custom.` alone names nothing.""" + got = normalise_view_config({ + "shown_fields": ["custom.budget", "custom.", "customer"], + }) + assert got["shown_fields"] == ["custom.budget"] + + +def test_a_duplicate_shown_field_is_kept_once(): + got = normalise_view_config({"shown_fields": ["status", "status", "tags"]}) + assert got["shown_fields"] == ["status", "tags"] + + +def test_an_absent_or_junk_shown_fields_stays_absent(): + """Absent means "the client's default set". Writing a default HERE would + freeze today's default into every stored view, so the key is simply not + emitted — mirroring how a lane-less view stores no lane keys.""" + assert "shown_fields" not in normalise_view_config({"group_by": "status"}) + for junk in ("status,tags", {"status": True}, 7, None, True): + assert "shown_fields" not in normalise_view_config({"shown_fields": junk}) + + +def test_an_explicitly_empty_shown_fields_list_is_kept(): + """Hiding every column is a choice, not the default — collapsing `[]` + into "absent" would un-hide a deliberate choice on the next apply.""" + assert normalise_view_config({"shown_fields": []})["shown_fields"] == [] + + def test_a_saved_view_and_the_same_filters_typed_by_hand_are_one_query(): """The whole reason the builder is shared. If these ever diverge, a saved view shows a different set of tasks than the filters it claims to hold.""" diff --git a/tests/unit/test_projects_hardening.py b/tests/unit/test_projects_hardening.py new file mode 100644 index 000000000..f2a65c28f --- /dev/null +++ b/tests/unit/test_projects_hardening.py @@ -0,0 +1,523 @@ +"""WS-27w — read-path and history hardening. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1 (WS-27w). +Rationale: ``specs/plane_pm_research_2026-08.md`` §3 (P-3, P-5, P-6, P-7, P-21). + +Six small corrections, and the two claims here that outlive this ticket are +STRUCTURAL, deliberately DB-free: + +* **every ``field_change`` goes through the one labelled door.** The walker + below reads the package's ``record_activity`` call sites out of the AST, so + a future call site written without label resolution fails this suite before + it ships a UUID into somebody's history. +* **every ``TASK_SORTS`` entry ends with the ``(created_at, id)`` tiebreaker.** + A sort without a total order lets a tie straddle a page boundary, and the + bug reads as "pagination sometimes loses a task" — months of mystery for one + missing clause. + +Everything behavioural runs hermetically against ``_projects_fakes`` — no +Postgres, no TestClient, ``_get_db`` monkeypatched per SUT module. +""" + +from __future__ import annotations + +import ast +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import automation as pm_automation +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import search as pm_search +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import tree as pm_tree + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + page, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_tree, pm_tasks, pm_activities, pm_search) +USER = projects_user() +COLLEAGUE = member_user("colleague@fracktal.in") + +PACKAGE = Path("apps/services/gateway/gateway/routes/projects") + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + return fake + + +@pytest.fixture +def events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict]]: + return silence_events(monkeypatch, MODULES) + + +def _workspace(db: FakeProjectsDB) -> tuple: + """One project with an open and a closed lane.""" + project = db.seed_project(name="Ops") + todo = db.seed_status(project.id, name="To do", category="todo", + is_default=True, position=10) + done = db.seed_status(project.id, name="Done", category="done", + is_default=False, position=40) + return project, todo, done + + +# ── 1 · the archive guard ─────────────────────────────────────────────────── + +async def test_archiving_an_open_task_is_422_naming_the_category( + db: FakeProjectsDB, events: list, +) -> None: + """⚠️ The trap this guard closes: an archived open task silently exits + every default list while the work is still owed (P-3). The refusal names + the ACTUAL category so the person knows which lane to leave first.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Still open") + + with pytest.raises(HTTPException) as exc: + await pm_tasks.archive_task(str(task.id), user=USER) + + assert exc.value.status_code == 422 + assert "'todo'" in exc.value.detail + # And nothing was written: the task is untouched. + assert next( + t for t in db.rows("pm_tasks") if str(t["id"]) == str(task.id) + )["archived_at"] is None + + +@pytest.mark.parametrize("category,name", [("done", "Done"), ("cancelled", "Won't do")]) +async def test_a_closed_task_archives_and_earns_a_timeline_row( + db: FakeProjectsDB, events: list, category: str, name: str, +) -> None: + """Both closing categories archive — `cancelled` counts as closed, the + same rule `completed_at` follows.""" + project = db.seed_project(name="Ops") + closed = db.seed_status(project.id, name=name, category=category) + task = db.seed_task(project.id, closed.id, title="Finished") + + result = await pm_tasks.archive_task(str(task.id), user=USER) + + assert result["archived_at"] is not None + assert any( + a["body"] == "Task archived" for a in db.activities("system") + ) + assert ("pm.task.archived", {"task_id": str(task.id)}) in events + + +async def test_the_guard_is_category_not_in_closing_never_a_list_of_open_ones() -> None: + """WS-27u is concurrently adding a `triage` category. Written as `not in + (done, cancelled)`, a new category is refused by default; written as a + list of open categories, it would be silently archivable. Pinned on the + source so the mutant that flips the predicate's direction goes red.""" + source = (PACKAGE / "tasks.py").read_text(encoding="utf-8") + assert "if category not in CLOSING_CATEGORIES:" in source + assert frozenset({"done", "cancelled"}) == pm_core.CLOSING_CATEGORIES + + +async def test_unarchive_restores_and_needs_no_category( + db: FakeProjectsDB, events: list, +) -> None: + """The way back has no guard: restoring puts work where people can see it, + which is never the trap the archive guard exists to prevent.""" + project, todo, _ = _workspace(db) + task = db.seed_task( + project.id, todo.id, title="Buried", archived_at=pm_core.now(), + ) + + result = await pm_tasks.unarchive_task(str(task.id), user=USER) + + assert result["archived_at"] is None + + +async def test_archiving_twice_is_idempotent_not_a_second_timeline_row( + db: FakeProjectsDB, events: list, +) -> None: + project = db.seed_project(name="Ops") + done = db.seed_status(project.id, name="Done", category="done") + task = db.seed_task(project.id, done.id, title="Finished") + + await pm_tasks.archive_task(str(task.id), user=USER) + await pm_tasks.archive_task(str(task.id), user=USER) + + assert len([a for a in db.activities("system") + if a["body"] == "Task archived"]) == 1 + + +# ── 2 · activity meta — the labelled door, structurally ───────────────────── + +def _field_change_call_sites() -> list[tuple[str, str, int]]: + """Every ``record_activity(activity_type="field_change", …)`` call in the + routes package, as ``(file, enclosing function, line)``. + + AST, not regex, because the thing being guarded is a CALL — a docstring or + a comment mentioning the words must not satisfy (or trip) the rule. + """ + sites: list[tuple[str, str, int]] = [] + for path in sorted(PACKAGE.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + # Parent links, so a call can name its innermost enclosing function. + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + child._parent = parent # type: ignore[attr-defined] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = getattr(func, "id", None) or getattr(func, "attr", None) + if name != "record_activity": + continue + kind = next( + (k.value.value for k in node.keywords + if k.arg == "activity_type" and isinstance(k.value, ast.Constant)), + None, + ) + if kind != "field_change": + continue + enclosing = node + while enclosing is not None and not isinstance( + enclosing, ast.FunctionDef | ast.AsyncFunctionDef, + ): + enclosing = getattr(enclosing, "_parent", None) + sites.append(( + path.name, + enclosing.name if enclosing is not None else "", + node.lineno, + )) + return sites + + +def test_every_field_change_call_site_goes_through_the_label_resolver() -> None: + """⚠️ THE structural rule (P-5). ``record_field_change`` is the one + function allowed to hand ``record_activity`` a ``field_change`` — it is + where FK ids gain their labels and where description edits coalesce. A + future call site that writes the type directly has, by construction, + skipped both, and this walker fails it by name and line.""" + sites = _field_change_call_sites() + # Self-check first: a walker that finds nothing is a walker that proves + # nothing — the one legitimate site must be visible to it. + assert sites, "the walker found no field_change call sites at all" + offenders = [ + f"{file}:{line} (in {function})" + for file, function, line in sites + if (file, function) != ("core.py", "record_field_change") + ] + assert not offenders, ( + "field_change activities must be written through " + "core.record_field_change, which resolves FK labels at write time; " + f"found direct call sites: {offenders}" + ) + + +def test_the_one_door_resolves_labels_before_it_writes() -> None: + """And the door itself cannot quietly lose the resolver: the call to + ``resolve_fk_labels`` precedes the write in its source.""" + import inspect + + source = inspect.getsource(pm_core.record_field_change) + assert source.index("resolve_fk_labels(") < source.index("record_activity(") + + +def test_every_tracked_fk_field_has_a_label_source() -> None: + """A tracked field named ``*_id`` is an FK by this package's own naming + convention; one without a row in ``FK_LABEL_FIELDS`` would pass through + the resolver untouched and store bare UUIDs — the exact defect item 2 + exists to close.""" + tracked = { + *pm_tasks._TRACKED_TASK_FIELDS, + *pm_automation.PATCHABLE_FIELDS, + *pm_tree._TRACKED_PROJECT_FIELDS, + } + missing = sorted( + name for name in tracked + if name.endswith("_id") and name not in pm_core.FK_LABEL_FIELDS + ) + assert not missing, f"FK-valued tracked fields without a label source: {missing}" + + +async def test_a_type_change_carries_ids_and_labels_resolved_at_write_time( + db: FakeProjectsDB, events: list, +) -> None: + """The behavioural half: patching ``type_id`` stores the five-key shape, + with the label read NOW — history renders without a join against a row + that may later be renamed or deleted.""" + project, todo, _ = _workspace(db) + bug = db.seed("pm_task_types", project_id=project.id, name="Bug") + task = db.seed_task(project.id, todo.id, title="Typed") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(type_id=str(bug.id)), user=USER, + ) + + [entry] = db.activities("field_change") + [change] = entry["meta"]["changes"] + assert change == { + "field": "type_id", + "old_id": None, + "new_id": str(bug.id), + "old_label": None, + "new_label": "Bug", + } + + +# ── 3 · description-edit coalescing ───────────────────────────────────────── + +async def test_same_actor_consecutive_description_edits_are_one_row( + db: FakeProjectsDB, events: list, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The autosave case (P-5): two edits in a row, one timeline row, spanning + first ``old`` to latest ``new`` — with the row's timestamp moved to the + latest edit, because the row now records that one.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + later = datetime(2026, 8, 9, 12, 0, tzinfo=UTC) + monkeypatch.setattr(pm_core, "now", lambda: later) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=USER, + ) + + [entry] = db.activities("field_change") + assert entry["meta"]["changes"] == [ + {"field": "description", "old": "v1", "new": "v3"}, + ] + assert entry["created_at"] == later + + +async def test_a_different_actor_breaks_the_run_two_rows( + db: FakeProjectsDB, events: list, +) -> None: + """Same field, different hands: coalescing across actors would attribute + one person's words to another.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=COLLEAGUE, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_an_intervening_activity_breaks_the_run( + db: FakeProjectsDB, events: list, +) -> None: + """Consecutive means IMMEDIATELY previous. A comment in between is part of + the story, and folding the second edit backwards over it would reorder + what actually happened.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_core.record_activity( + db, activity_type="comment", created_by="owner@fracktal.in", + task_id=str(task.id), body="looks good", + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3"), user=USER, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_a_multi_field_edit_never_coalesces( + db: FakeProjectsDB, events: list, +) -> None: + """Description + importance in one PATCH is not an autosave run; folding + it into a prior description row would hide the importance change.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc", description="v1") + + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v2"), user=USER, + ) + await pm_tasks.patch_task( + str(task.id), pm_core.TaskIn(description="v3", importance=4), user=USER, + ) + + assert len(db.activities("field_change")) == 2 + + +async def test_editing_a_comment_twice_updates_the_row_never_appends( + db: FakeProjectsDB, events: list, +) -> None: + """The comment-body half: the comment IS the activity row, so an edit — + and an edit of the edit — rewrites it in place and bumps its timestamp. + Two edits, one comment row, no side rows.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Doc") + comment = db.seed( + "pm_activities", task_id=str(task.id), type="comment", + body="first", created_by="owner@fracktal.in", + ) + before = next( + a for a in db.rows("pm_activities") if str(a["id"]) == str(comment.id) + )["updated_at"] + + await pm_activities.edit_comment( + str(comment.id), pm_activities.CommentIn(body="second"), user=USER, + ) + await pm_activities.edit_comment( + str(comment.id), pm_activities.CommentIn(body="third"), user=USER, + ) + + rows = [a for a in db.rows("pm_activities") if a["type"] == "comment"] + assert len(rows) == 1 + assert rows[0]["body"] == "third" + assert rows[0]["updated_at"] > before + assert len(db.rows("pm_activities")) == 1 # nothing appended beside it + + +# ── 4 · semantic sorts, and the tiebreaker rule ───────────────────────────── + +def test_every_task_sort_ends_with_the_created_at_id_tiebreaker() -> None: + """⚠️ Structural (P-6). Without a total order, two tasks tying on the sort + key can straddle a page boundary — appearing on both pages or neither + depending on the plan — and the report is "pagination loses tasks".""" + for key, value in pm_core.TASK_SORTS.items(): + assert value.endswith(pm_core.SORT_TIEBREAK), ( + f"TASK_SORTS[{key!r}] must end with the deterministic " + f"{pm_core.SORT_TIEBREAK!r} tiebreaker; got: {value!r}" + ) + + +def test_the_status_sort_is_semantic_never_alphabetical() -> None: + """Category rank then lane position — never the status NAME, which would + put "Backlog" before "Done" only by accident of language and reshuffle the + whole list on every lane rename.""" + status = pm_core.TASK_SORTS["status"] + assert "array_position" in status and "s.category" in status + assert "s.position" in status + assert "s.name" not in status + # And the rank IS the vocabulary, in lifecycle order — not a second list. + for category in pm_core.STATUS_CATEGORIES: + assert f"'{category}'" in status + + +async def test_sorting_by_status_orders_by_category_rank_then_position( + db: FakeProjectsDB, events: list, +) -> None: + """The crafted fixture: lane NAMES chosen so alphabetical order is exactly + wrong — 'Aaa Done' would sort first by name and must sort last by rank — + and two same-category lanes to prove position breaks the tie.""" + project = db.seed_project(name="Ops") + done = db.seed_status(project.id, name="Aaa Done", category="done", + position=40, is_default=False) + doing_b = db.seed_status(project.id, name="Bbb Doing", category="in_progress", + position=30, is_default=False) + doing_z = db.seed_status(project.id, name="Zzz Doing first", category="in_progress", + position=20, is_default=False) + todo = db.seed_status(project.id, name="Yyy To do", category="todo", + position=10, is_default=True) + + db.seed_task(project.id, done.id, title="finished") + db.seed_task(project.id, doing_b.id, title="doing, later lane") + db.seed_task(project.id, doing_z.id, title="doing, earlier lane") + db.seed_task(project.id, todo.id, title="queued") + + result = await pm_tasks.list_tasks( + user=USER, sort="status", direction="asc", page=page(), + ) + + assert [row["title"] for row in result.rows] == [ + "queued", # todo ranks before in_progress + "doing, earlier lane", # same category: position 20 before 30 + "doing, later lane", + "finished", # done ranks last, despite the 'Aaa' name + ] + + +# ── 5 · picker exclusions on search ───────────────────────────────────────── + +def _family(db: FakeProjectsDB) -> dict: + """Anchor with an ancestor, descendants, relations both ways, a bystander.""" + project, todo, _ = _workspace(db) + parent = db.seed_task(project.id, todo.id, title="pick parent") + anchor = db.seed_task(project.id, todo.id, title="pick anchor", + parent_task_id=parent.id) + child = db.seed_task(project.id, todo.id, title="pick child", + parent_task_id=anchor.id) + db.seed_task(project.id, todo.id, title="pick grandchild", + parent_task_id=child.id) + outgoing = db.seed_task(project.id, todo.id, title="pick outgoing relation") + incoming = db.seed_task(project.id, todo.id, title="pick incoming relation") + db.seed("pm_task_links", source_task_id=anchor.id, + target_task_id=outgoing.id, link_type="relates_to") + db.seed("pm_task_links", source_task_id=incoming.id, + target_task_id=anchor.id, link_type="blocks") + bystander = db.seed_task(project.id, todo.id, title="pick bystander") + return {"anchor": anchor, "bystander": bystander} + + +async def test_exclude_relatives_of_hides_all_four_classes( + db: FakeProjectsDB, events: list, +) -> None: + """Self, ancestors, descendants, related-in-either-direction: everything + the write path would 422 (or merely re-assert), so the picker cannot offer + it. The bystander is the control — exclusion must not become an empty + answer.""" + family = _family(db) + + result = await pm_search.search_tasks( + q="pick", exclude_relatives_of=str(family["anchor"].id), user=USER, + ) + + assert [r["title"] for r in result["rows"]] == ["pick bystander"] + + +async def test_without_the_parameter_search_is_unchanged( + db: FakeProjectsDB, events: list, +) -> None: + family = _family(db) + result = await pm_search.search_tasks(q="pick", user=USER) + titles = {r["title"] for r in result["rows"]} + assert family["anchor"].title in titles + assert len(titles) == 7 + + +async def test_an_unreadable_anchor_is_404_not_an_oracle( + db: FakeProjectsDB, events: list, +) -> None: + """R5: filtering by a task you cannot see must answer exactly what "no + such task" answers, or the parameter probes for existence.""" + secret = db.seed_project(name="Secret", subject=None) + status = db.seed_status(secret.id, name="To do", category="todo") + hidden = db.seed_task(secret.id, status.id, title="hidden anchor") + db.seed_project(name="Mine", subject="colleague@fracktal.in") + + with pytest.raises(HTTPException) as exc: + await pm_search.search_tasks( + q="anything", exclude_relatives_of=str(hidden.id), user=COLLEAGUE, + ) + assert exc.value.status_code == 404 + + +async def test_the_write_time_guards_stay(db: FakeProjectsDB) -> None: + """Item 5's last clause: the read-side exclusion is a twin, not a + replacement — self-linking still 422s at the write.""" + project, todo, _ = _workspace(db) + task = db.seed_task(project.id, todo.id, title="Self") + + with pytest.raises(HTTPException) as exc: + await pm_tasks.create_link( + str(task.id), + pm_tasks.LinkIn(target_task_id=str(task.id)), + user=USER, + ) + assert exc.value.status_code == 422 diff --git a/tests/unit/test_projects_import_tasks.py b/tests/unit/test_projects_import_tasks.py index 7770ee4d0..6d688f825 100644 --- a/tests/unit/test_projects_import_tasks.py +++ b/tests/unit/test_projects_import_tasks.py @@ -80,17 +80,11 @@ def test_every_category_it_maps_to_is_one_the_DATABASE_allows(): """ from gateway.routes.projects.filters import STATUS_CATEGORIES - text = ( - __import__("pathlib").Path(__file__).resolve().parents[2] - / "infra/postgres/146_projects.sql" - ).read_text(encoding="utf-8") - text = "\n".join(re.sub(r"--.*$", "", ln) for ln in text.splitlines()) - match = re.search( - r"category\s+TEXT\s+NOT\s+NULL[^,]*?CHECK\s*\(\s*category\s+IN\s*\((.*?)\)\)", - text, re.S | re.I, - ) - assert match, "146 no longer constrains pm_task_statuses.category" - allowed = set(re.findall(r"'([a-z_]+)'", match.group(1))) + # The LAST migration to constrain the column wins a full replay — 146 + # created the CHECK inline and 164 (WS-27u) replaced it to admit `triage`. + from tests.unit.test_projects_filters import migrated_status_categories + + allowed = migrated_status_categories() assert set(CATEGORY_BY_NAME.values()) <= allowed, ( f"the importer would write categories the database refuses: " f"{sorted(set(CATEGORY_BY_NAME.values()) - allowed)}" diff --git a/tests/unit/test_projects_intake.py b/tests/unit/test_projects_intake.py new file mode 100644 index 000000000..83254fb2f --- /dev/null +++ b/tests/unit/test_projects_intake.py @@ -0,0 +1,646 @@ +"""WS-27u — intake/triage: the front door. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §9.1 (WS-27u). + +Hermetic, per the standing protocol: no Postgres, no TestClient — route +functions called directly against ``_projects_fakes.FakeProjectsDB`` with +``_get_db`` monkeypatched per module. + +The claims worth pinning, because each has a plausible wrong implementation: + +* **capture is ONE transaction** — a task without a wrapper is a capture with + no provenance; a wrapper without a task points at nothing. +* **accept flips the status IN PLACE** — a copy-on-accept forks the history at + exactly the moment it starts to matter, so "the table still holds one task" + is asserted, not assumed. +* **the wrapper is permanent** — all four rulings UPDATE it; nothing deletes + it, and that is pinned both behaviourally and structurally (no ``DELETE + FROM pm_intake`` anywhere in the module). +* **the default-list exclusion is one predicate, honoured by every surface** — + list, calendar and search each hide triage-parked tasks unless + ``include_triage`` is passed, and a structural test keeps any surface from + silently dropping the flag (FastAPI ignores unknown query parameters — the + §11.16 trap, extended here as the ticket requires). +* **snooze reappears by being read** — ``snoozed_until <= now()`` in the queue + query, no worker, so the test moves the clock by editing the row rather than + by flipping any status. +* **R5** — capture into an unseen project, and every ruling on an unseen task, + is a 404, never a 403; the queue is scoped by the same grants as the tasks. +""" + +from __future__ import annotations + +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import calendar as pm_calendar +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import intake as pm_intake +from gateway.routes.projects import search as pm_search +from gateway.routes.projects import tasks as pm_tasks + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + page, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_intake, pm_tasks, pm_calendar, pm_search) +USER = projects_user() + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + return fake + + +@pytest.fixture +def events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict]]: + return silence_events(monkeypatch, MODULES) + + +def _workspace(db: FakeProjectsDB, *, subject: str | None = "org") -> tuple: + project = db.seed_project(name="Ops", subject=subject) + todo = db.seed_status(project.id, name="To do", category="todo", + is_default=True, position=20) + return project, todo + + +def _triage(db: FakeProjectsDB, project) -> object: + return db.seed_status(project.id, name="Triage", category="triage", + is_default=False, position=5) + + +def _captured(db: FakeProjectsDB, project, triage, **columns) -> object: + """A parked task with its wrapper — the state every ruling starts from.""" + task = db.seed_task(project.id, triage.id, **columns) + db.seed("pm_intake", task_id=task.id, status="pending", + created_by="owner@fracktal.in") + return task + + +def _intake_activities(db: FakeProjectsDB) -> list[dict]: + return [ + a for a in db.activities("system") + if isinstance(a.get("meta"), dict) and "intake" in a["meta"] + ] + + +# ── Capture — task + wrapper, one transaction ─────────────────────────────── + +async def test_capture_creates_task_and_wrapper_in_one_transaction( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + _triage(db, project) + + result = await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=str(project.id), title="From the inbox", + source="email", source_ref="msg-123"), + user=USER, + ) + + # ONE commit covering both writes and the timeline entry — the contract, + # not an optimisation: neither half may ever exist alone. + assert db.committed == 1 + tasks = db.rows("pm_tasks") + wrappers = db.rows("pm_intake") + assert len(tasks) == 1 and len(wrappers) == 1 + assert str(wrappers[0]["task_id"]) == str(tasks[0]["id"]) + assert wrappers[0]["status"] == "pending" + assert wrappers[0]["source"] == "email" + assert wrappers[0]["source_ref"] == "msg-123" + assert result["intake"]["status"] == "pending" + assert result["task"]["title"] == "From the inbox" + # A capture from a channel the task vocabulary knows is stamped on the + # task too, so it reads like an email-created task everywhere else. + assert tasks[0]["source"] == "email" + # The birth is on the timeline. + assert any(a["meta"]["intake"] == "captured" for a in _intake_activities(db)) + assert ("pm.intake.captured", { + "task_id": result["task"]["id"], "project_id": str(project.id), + "title": "From the inbox", "source": "email", + }) in events + + +async def test_a_captured_task_lands_in_a_triage_category_status( + db: FakeProjectsDB, events: list, +) -> None: + """The status IS the parking mechanism — the wrapper alone hides nothing.""" + project, _ = _workspace(db) + triage = _triage(db, project) + + result = await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=str(project.id), title="Parked"), + user=USER, + ) + assert result["task"]["status_id"] == str(triage.id) + + +async def test_capture_provisions_a_triage_status_when_the_project_has_none( + db: FakeProjectsDB, events: list, +) -> None: + """Provisioned on first use, following how defaults are seeded — and never + `is_default`, so an ordinary create can never land in it by omission.""" + project, _ = _workspace(db) + + result = await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=str(project.id), title="First capture"), + user=USER, + ) + + lanes = [s for s in db.rows("pm_task_statuses") if s["category"] == "triage"] + assert len(lanes) == 1 + assert lanes[0]["is_default"] is False + assert result["task"]["status_id"] == str(lanes[0]["id"]) + + # The second capture REUSES it rather than minting a lane per capture. + await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=str(project.id), title="Second capture"), + user=USER, + ) + assert len([s for s in db.rows("pm_task_statuses") + if s["category"] == "triage"]) == 1 + + +async def test_capture_requires_a_title_and_a_project( + db: FakeProjectsDB, events: list, +) -> None: + with pytest.raises(HTTPException) as exc: + await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=None, title="x"), user=USER, + ) + assert exc.value.status_code == 422 + with pytest.raises(HTTPException) as exc: + await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id="p", title=" "), user=USER, + ) + assert exc.value.status_code == 422 + + +# ── The default-list exclusion — every surface, one predicate ─────────────── + +async def test_triage_tasks_are_invisible_to_the_task_list_by_default( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + db.seed_task(project.id, todo.id, title="Real work") + db.seed_task(project.id, triage.id, title="Parked capture") + + listed = await pm_tasks.list_tasks(user=USER, page=page()) + assert [r["title"] for r in listed.rows] == ["Real work"] + assert listed.total == 1 + + included = await pm_tasks.list_tasks(user=USER, page=page(), + include_triage=True) + assert included.total == 2 + + +async def test_triage_tasks_are_invisible_to_the_calendar_and_timeline( + db: FakeProjectsDB, events: list, +) -> None: + """One endpoint serves both renderings (§11.17), so this covers both.""" + project, todo = _workspace(db) + triage = _triage(db, project) + db.seed_task(project.id, todo.id, title="Real", + due_at="2026-08-10T10:00:00Z") + db.seed_task(project.id, triage.id, title="Parked", + due_at="2026-08-11T10:00:00Z") + + month = await pm_calendar.get_calendar( + user=USER, date_from="2026-08-01", date_to="2026-09-01", + ) + assert [r["title"] for r in month["rows"]] == ["Real"] + + included = await pm_calendar.get_calendar( + user=USER, date_from="2026-08-01", date_to="2026-09-01", + include_triage=True, + ) + assert {r["title"] for r in included["rows"]} == {"Real", "Parked"} + + +async def test_triage_tasks_are_invisible_to_search_by_default( + db: FakeProjectsDB, events: list, +) -> None: + """Search reaches every task in the app — the surface where leaking the + queue costs most, and the one the duplicate picker needs the flag on.""" + project, todo = _workspace(db) + triage = _triage(db, project) + db.seed_task(project.id, todo.id, title="widget refactor") + db.seed_task(project.id, triage.id, title="widget capture") + + hidden = await pm_search.search_tasks(q="widget", user=USER) + assert [r["title"] for r in hidden["rows"]] == ["widget refactor"] + + shown = await pm_search.search_tasks(q="widget", include_triage=True, + user=USER) + assert {r["title"] for r in shown["rows"]} == { + "widget refactor", "widget capture", + } + + +def test_no_surface_can_silently_drop_include_triage() -> None: + """⚠️ The §11.16 parameter-coverage rule, extended to WS-27u's flag. + + FastAPI ignores an unknown query parameter, so a surface that stops + declaring ``include_triage`` would not error — the flag would quietly stop + applying on that surface, which reads as the FILTER breaking. Board and + list share ``/projects/tasks``; calendar and timeline share + ``/projects/calendar`` (§11.17); search is its own read. + """ + from gateway.routes.projects import router + + def params(path: str) -> set[str]: + route = next(r for r in router.routes if r.path == path) + return {p.name for p in route.dependant.query_params} + + for path in ("/projects/tasks", "/projects/calendar", "/projects/search"): + assert "include_triage" in params(path), ( + f"{path} does not declare include_triage — FastAPI will drop it " + f"silently and the surface will hide the queue with no way to ask" + ) + + +def test_the_exclusion_predicate_has_exactly_one_copy() -> None: + """The ticket's own wording: ONE predicate in core.py beside the + visibility clause, never pasted per surface. The clause's private alias is + its fingerprint, so a hand-written copy in a route module fails here.""" + package = Path(pm_core.__file__).parent + # The dotted form, because the bare alias is a substring of `is_triaged` + # (the personal overlay's flag) — a fingerprint that matches prose is not + # a fingerprint. + owners = sorted( + path.name for path in package.glob("*.py") + if "s_triage." in path.read_text(encoding="utf-8") + ) + assert owners == ["core.py"], ( + f"the triage predicate is written out in {owners}; every surface must " + f"route through core.triage_exclusion_clause" + ) + + +# ── The queue ─────────────────────────────────────────────────────────────── + +async def test_the_queue_lists_pending_and_reappearing_snoozes( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + pending = _captured(db, project, triage, title="Pending") + woke = db.seed_task(project.id, triage.id, title="Snooze elapsed") + db.seed("pm_intake", task_id=woke.id, status="snoozed", + snoozed_until=datetime.now(UTC) - timedelta(hours=1), + created_by="owner@fracktal.in") + still = db.seed_task(project.id, triage.id, title="Still snoozed") + db.seed("pm_intake", task_id=still.id, status="snoozed", + snoozed_until=datetime.now(UTC) + timedelta(days=2), + created_by="owner@fracktal.in") + ruled = db.seed_task(project.id, todo.id, title="Already accepted") + db.seed("pm_intake", task_id=ruled.id, status="accepted", + created_by="owner@fracktal.in") + + queue = await pm_intake.list_intake(user=USER, page=page()) + assert {r["title"] for r in queue["rows"]} == {"Pending", "Snooze elapsed"} + assert queue["total"] == 2 + by_title = {r["title"]: r for r in queue["rows"]} + assert by_title["Pending"]["intake"]["status"] == "pending" + assert by_title["Snooze elapsed"]["intake"]["status"] == "snoozed" + assert str(by_title["Pending"]["id"]) == str(pending.id) + + +async def test_the_queue_scopes_by_a_projects_subtree( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + triage = _triage(db, project) + child = db.seed_project(name="Sub", parent=project.id, subject=None) + other, _ = _workspace(db) + other_triage = _triage(db, other) + inside = db.seed_task(child.id, triage.id, root=project.id, title="Inside") + db.seed("pm_intake", task_id=inside.id, created_by="owner@fracktal.in") + _captured(db, other, other_triage, title="Elsewhere") + + queue = await pm_intake.list_intake( + user=USER, project_id=str(project.id), page=page(), + ) + assert [r["title"] for r in queue["rows"]] == ["Inside"] + + +# ── Accept — flips in place, never copies ─────────────────────────────────── + +async def test_accept_flips_the_status_in_place_and_never_copies( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage, title="Keep me") + + result = await pm_intake.accept_intake( + str(task.id), pm_intake.AcceptIn(status_id=str(todo.id)), user=USER, + ) + + # IN PLACE: same row, same id — the table still holds exactly one task. + assert len(db.rows("pm_tasks")) == 1 + assert result["task"]["id"] == str(task.id) + assert db.rows("pm_tasks")[0]["status_id"] == str(todo.id) + assert result["intake"]["status"] == "accepted" + # Both timeline entries: the transition's own, and the ruling's. + assert db.activities("status_change") + assert any(a["meta"]["intake"] == "accepted" for a in _intake_activities(db)) + assert ("pm.intake.accepted", {"task_id": str(task.id)}) in events + + +async def test_accept_without_a_status_uses_the_projects_default( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage) + + result = await pm_intake.accept_intake( + str(task.id), pm_intake.AcceptIn(), user=USER, + ) + assert result["task"]["status_id"] == str(todo.id) + + +async def test_accept_refuses_a_triage_destination( + db: FakeProjectsDB, events: list, +) -> None: + """Accepting INTO triage rules nothing — and the guard also catches a + project whose status fallback happens to be the triage lane itself.""" + project, _ = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage) + + with pytest.raises(HTTPException) as exc: + await pm_intake.accept_intake( + str(task.id), pm_intake.AcceptIn(status_id=str(triage.id)), + user=USER, + ) + assert exc.value.status_code == 422 + # Nothing moved and the wrapper still awaits a ruling. + assert db.rows("pm_intake")[0]["status"] == "pending" + + +# ── Decline and duplicate — archive, wrapper as provenance ────────────────── + +async def test_decline_archives_the_task_and_keeps_the_wrapper( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage, title="Not work") + + result = await pm_intake.decline_intake(str(task.id), user=USER) + + row = db.rows("pm_tasks")[0] + assert row["archived_at"] is not None + assert result["intake"]["status"] == "declined" + # PROVENANCE IS PERMANENT: the wrapper survives the ruling. + assert len(db.rows("pm_intake")) == 1 + assert any(a["meta"]["intake"] == "declined" for a in _intake_activities(db)) + # And the declined capture is out of the queue. + queue = await pm_intake.list_intake(user=USER, page=page()) + assert queue["total"] == 0 + + +async def test_duplicate_points_at_the_original_and_archives( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + original = db.seed_task(project.id, todo.id, title="The original") + task = _captured(db, project, triage, title="Same thing again") + + result = await pm_intake.duplicate_intake( + str(task.id), + pm_intake.DuplicateIn(duplicate_of_task_id=str(original.id)), + user=USER, + ) + + wrapper = db.rows("pm_intake")[0] + assert str(wrapper["duplicate_of_task_id"]) == str(original.id) + assert wrapper["status"] == "duplicate" + assert result["task"]["archived_at"] is not None + assert any( + a["meta"]["intake"] == "duplicate" + and a["meta"]["duplicate_of_task_id"] == str(original.id) + for a in _intake_activities(db) + ) + + +async def test_a_task_cannot_be_a_duplicate_of_itself( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage) + + with pytest.raises(HTTPException) as exc: + await pm_intake.duplicate_intake( + str(task.id), + pm_intake.DuplicateIn(duplicate_of_task_id=str(task.id)), + user=USER, + ) + assert exc.value.status_code == 422 + + +# ── Snooze — hidden until, reappears by being read ────────────────────────── + +async def test_snooze_hides_the_item_until_its_instant_passes( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage, title="Later") + + result = await pm_intake.snooze_intake( + str(task.id), pm_intake.SnoozeIn(until="2099-01-01T09:00:00Z"), + user=USER, + ) + assert result["intake"]["status"] == "snoozed" + + queue = await pm_intake.list_intake(user=USER, page=page()) + assert queue["total"] == 0 + + # Time passes — modelled by editing the ROW, because that is the claim: + # nothing flips a snoozed wrapper back, it reappears by being read. + db.rows("pm_intake")[0]["snoozed_until"] = ( + datetime.now(UTC) - timedelta(minutes=1) + ) + woke = await pm_intake.list_intake(user=USER, page=page()) + assert [r["title"] for r in woke["rows"]] == ["Later"] + assert db.rows("pm_intake")[0]["status"] == "snoozed" + + +async def test_a_snooze_into_the_past_is_refused( + db: FakeProjectsDB, events: list, +) -> None: + project, _ = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage) + + with pytest.raises(HTTPException) as exc: + await pm_intake.snooze_intake( + str(task.id), pm_intake.SnoozeIn(until="2020-01-01T00:00:00Z"), + user=USER, + ) + assert exc.value.status_code == 422 + with pytest.raises(HTTPException) as exc: + await pm_intake.snooze_intake( + str(task.id), pm_intake.SnoozeIn(until="whenever"), user=USER, + ) + assert exc.value.status_code == 422 + + +# ── The wrapper is permanent ──────────────────────────────────────────────── + +async def test_no_ruling_deletes_the_wrapper( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db) + triage = _triage(db, project) + kept = _captured(db, project, triage, title="kept") + declined = _captured(db, project, triage, title="declined") + doubled = _captured(db, project, triage, title="doubled") + original = db.seed_task(project.id, todo.id, title="original") + + await pm_intake.accept_intake( + str(kept.id), pm_intake.AcceptIn(status_id=str(todo.id)), user=USER, + ) + await pm_intake.decline_intake(str(declined.id), user=USER) + await pm_intake.duplicate_intake( + str(doubled.id), + pm_intake.DuplicateIn(duplicate_of_task_id=str(original.id)), + user=USER, + ) + assert len(db.rows("pm_intake")) == 3 + + +def test_the_module_never_writes_a_delete_against_the_wrapper() -> None: + """Structural half of permanence: the behavioural test above proves three + rulings kept three rows; this proves no code path CAN drop one.""" + source = Path(pm_intake.__file__).read_text(encoding="utf-8") + assert not re.search(r"DELETE\s+FROM\s+pm_intake", source, re.I) + + +async def test_a_resolved_item_cannot_be_ruled_on_again( + db: FakeProjectsDB, events: list, +) -> None: + """Terminal states are terminal — a second ruling would overwrite the + provenance the first one wrote.""" + project, todo = _workspace(db) + triage = _triage(db, project) + task = _captured(db, project, triage) + await pm_intake.decline_intake(str(task.id), user=USER) + + with pytest.raises(HTTPException) as exc: + await pm_intake.accept_intake( + str(task.id), pm_intake.AcceptIn(status_id=str(todo.id)), user=USER, + ) + assert exc.value.status_code == 422 + assert "declined" in str(exc.value.detail) + + +# ── Visibility — R5, and grant scoping ────────────────────────────────────── + +async def test_capturing_into_an_unseen_project_is_404_never_403( + db: FakeProjectsDB, events: list, +) -> None: + project = db.seed_project(name="Secret", subject=None) + member = member_user("colleague@fracktal.in") + + with pytest.raises(HTTPException) as exc: + await pm_intake.capture_intake( + pm_intake.IntakeIn(project_id=str(project.id), title="Try"), + user=member, + ) + assert exc.value.status_code == 404 + + +async def test_ruling_on_an_unseen_task_is_404_never_403( + db: FakeProjectsDB, events: list, +) -> None: + project, todo = _workspace(db, subject=None) + triage = _triage(db, project) + task = _captured(db, project, triage) + member = member_user("colleague@fracktal.in") + + for act in ( + pm_intake.accept_intake( + str(task.id), pm_intake.AcceptIn(status_id=str(todo.id)), + user=member, + ), + pm_intake.decline_intake(str(task.id), user=member), + pm_intake.snooze_intake( + str(task.id), pm_intake.SnoozeIn(until="2099-01-01T00:00:00Z"), + user=member, + ), + ): + with pytest.raises(HTTPException) as exc: + await act + assert exc.value.status_code == 404 + + +async def test_marking_duplicate_of_an_unseen_original_is_404( + db: FakeProjectsDB, events: list, +) -> None: + """Both ends must be visible — accepting an unreadable original would + disclose that it exists, the same rule links already hold.""" + member = member_user("colleague@fracktal.in") + mine = db.seed_project(name="Mine", subject="colleague@fracktal.in") + triage = _triage(db, mine) + task = _captured(db, mine, triage) + hidden = db.seed_project(name="Hidden", subject=None) + theirs = db.seed_status(hidden.id, name="To do", category="todo") + original = db.seed_task(hidden.id, theirs.id, title="Unseen") + + with pytest.raises(HTTPException) as exc: + await pm_intake.duplicate_intake( + str(task.id), + pm_intake.DuplicateIn(duplicate_of_task_id=str(original.id)), + user=member, + ) + assert exc.value.status_code == 404 + + +async def test_the_queue_is_scoped_by_the_tasks_own_grants( + db: FakeProjectsDB, events: list, +) -> None: + """The queue can never show a capture its reader could not open.""" + member = member_user("colleague@fracktal.in") + granted = db.seed_project(name="Granted", subject="colleague@fracktal.in") + granted_triage = _triage(db, granted) + visible = _captured(db, granted, granted_triage, title="Yours to rule on") + hidden = db.seed_project(name="Hidden", subject=None) + hidden_triage = _triage(db, hidden) + _captured(db, hidden, hidden_triage, title="Not yours") + + queue = await pm_intake.list_intake(user=member, page=page()) + assert [r["title"] for r in queue["rows"]] == ["Yours to rule on"] + assert queue["total"] == 1 + assert str(queue["rows"][0]["id"]) == str(visible.id) + + +async def test_the_queue_404s_an_unseen_project_filter( + db: FakeProjectsDB, events: list, +) -> None: + """An unreadable `project_id` must not answer an empty queue — that would + confirm the project exists and is simply quiet (R5).""" + hidden = db.seed_project(name="Hidden", subject=None) + member = member_user("colleague@fracktal.in") + + with pytest.raises(HTTPException) as exc: + await pm_intake.list_intake( + user=member, project_id=str(hidden.id), page=page(), + ) + assert exc.value.status_code == 404 diff --git a/tests/unit/test_projects_lifecycle.py b/tests/unit/test_projects_lifecycle.py new file mode 100644 index 000000000..a0371c5b4 --- /dev/null +++ b/tests/unit/test_projects_lifecycle.py @@ -0,0 +1,609 @@ +"""WS-27z — lifecycle policy: auto-archive and auto-close. + +Spec: `ai-company-brain/specs/project_management_app.md` §9.1 (WS-27z), D6. + +Four layers, one ticket, and each is tested where it lives: + +1. **the migration** (166) — three ROOT-project columns, read as TEXT the way + every projects migration is (§10 runs no database); +2. **the settings surface** — the ordinary project PATCH/read path, with the + 422s that keep a bad policy out of the table; +3. **the sweep** — `automation.run_lifecycle_sweep`, the function a published + `/workflows` workflow calls on a schedule (D6: the engine owns WHEN, these + columns own WHAT); archive/close/exempt/idempotent, and the + project-timezone midnight beating UTC's; +4. **the flag and the wiring** — `automation: true` on every engine-made + activity row, and the `pm_lifecycle` node that reaches the sweep through + the same seam `pm_task` reaches `apply_task_patch`. + +Hermetic throughout: `_projects_fakes.FakeProjectsDB`, no Postgres, no +TestClient. +""" + +from __future__ import annotations + +import asyncio +import re +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import tree +from gateway.routes.projects.automation import ( + apply_task_patch, + run_lifecycle_sweep, + workflow_actor, +) +from gateway.routes.projects.core import ( + LIFECYCLE_FIELDS, + ProjectIn, + apply_status_transition, + record_field_change, + validate_lifecycle_settings, +) +from gateway.routes.workflows.catalog import NODE_TYPE_META +from gateway.routes.workflows.engine.graph import ( + NODE_TYPES, + compile_graph, + validate_graph, +) +from gateway.routes.workflows.engine.handlers import ( + NODE_TIMEOUTS, + NodeExecutionError, + NodeServices, + execute_node, +) + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + projects_user, + silence_events, +) + +MIGRATIONS = Path(__file__).resolve().parents[2] / "infra" / "postgres" + +USER = projects_user() + + +def run(coro): + return asyncio.run(coro) + + +# ── 1 · the migration, as text ────────────────────────────────────────────── + +def _lifecycle_migration() -> Path: + """Found by CONTENT (the R1 rule), not by number.""" + found = [ + path for path in sorted(MIGRATIONS.glob("*.sql")) + if path.name != "schema.generated.sql" + and "archive_after_months" in path.read_text(encoding="utf-8") + ] + assert len(found) == 1, ( + f"expected exactly one migration adding archive_after_months, found " + f"{[p.name for p in found]}" + ) + return found[0] + + +@pytest.fixture(scope="module") +def sql() -> str: + return _lifecycle_migration().read_text(encoding="utf-8") + + +def test_the_three_columns_land_on_pm_projects(sql: str) -> None: + assert "ALTER TABLE pm_projects" in sql + for column in LIFECYCLE_FIELDS: + assert f"ADD COLUMN IF NOT EXISTS {column}" in sql, column + + +def test_every_add_column_is_guarded(sql: str) -> None: + """Idempotent per infra/postgres/README.md: the ladder replays on every + deploy, and the CHECKs ride inside the guarded ADDs so a replay cannot + stack a second constraint.""" + adds = re.findall(r"ADD COLUMN(?! IF NOT EXISTS)", sql) + assert adds == [], f"unguarded ADD COLUMN: {adds}" + + +def test_the_months_columns_refuse_zero_and_negative(sql: str) -> None: + assert re.search(r"archive_after_months\s+INT\s+CHECK \(archive_after_months > 0\)", sql) + assert re.search(r"close_after_months\s+INT\s+CHECK \(close_after_months > 0\)", sql) + + +def test_the_timezone_defaults_to_utc_and_cannot_be_null(sql: str) -> None: + assert re.search(r"timezone\s+TEXT\s+NOT NULL\s+DEFAULT 'UTC'", sql) + + +def test_null_means_off_is_the_default(sql: str) -> None: + """No DEFAULT on either months column: a project never opts in by being + created (the sweeper touches real data — enable per project).""" + for column in ("archive_after_months", "close_after_months"): + line = next(l for l in sql.splitlines() if f"IF NOT EXISTS {column}" in l) + assert "DEFAULT" not in line, line + + +# ── 2 · the settings surface ──────────────────────────────────────────────── + +@pytest.fixture() +def db(monkeypatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, (tree,)) + silence_events(monkeypatch, (tree,)) + return fake + + +def test_the_policy_saves_on_a_root_and_reads_back(db: FakeProjectsDB) -> None: + root = db.seed_project(name="Delivery") + out = run(tree.patch_node( + str(root.id), + ProjectIn( + archive_after_months=3, close_after_months=6, + timezone="Asia/Kolkata", + ), + user=USER, + )) + assert out["archive_after_months"] == 3 + assert out["close_after_months"] == 6 + assert out["timezone"] == "Asia/Kolkata" + # And the read path carries them — the same row, the same mapper. + fresh = run(tree.get_node(str(root.id), user=USER)) + assert fresh["archive_after_months"] == 3 + + +def test_a_policy_change_earns_a_timeline_entry(db: FakeProjectsDB) -> None: + """Six months later, "who turned the sweeper on" must be a timeline read.""" + root = db.seed_project(name="Delivery") + run(tree.patch_node( + str(root.id), ProjectIn(archive_after_months=3), user=USER, + )) + entries = db.activities("field_change") + assert len(entries) == 1 + fields = {c["field"] for c in entries[0]["meta"]["changes"]} + assert fields == {"archive_after_months"} + + +@pytest.mark.parametrize("months", [0, -1]) +def test_zero_and_negative_months_are_422(db: FakeProjectsDB, months: int) -> None: + root = db.seed_project(name="Delivery") + with pytest.raises(HTTPException) as err: + run(tree.patch_node( + str(root.id), ProjectIn(archive_after_months=months), user=USER, + )) + assert err.value.status_code == 422 + assert "archive_after_months" in str(err.value.detail) + + +def test_a_fake_timezone_is_422_naming_the_zone(db: FakeProjectsDB) -> None: + root = db.seed_project(name="Delivery") + with pytest.raises(HTTPException) as err: + run(tree.patch_node( + str(root.id), ProjectIn(timezone="Mars/Olympus_Mons"), user=USER, + )) + assert err.value.status_code == 422 + assert "Mars/Olympus_Mons" in str(err.value.detail) + assert "IANA" in str(err.value.detail) + + +def test_a_null_timezone_is_422_not_a_silent_utc(db: FakeProjectsDB) -> None: + root = db.seed_project(name="Delivery") + with pytest.raises(HTTPException) as err: + run(tree.patch_node(str(root.id), ProjectIn(timezone=None), user=USER)) + assert err.value.status_code == 422 + + +def test_the_policy_is_refused_on_a_subproject(db: FakeProjectsDB) -> None: + """Root-project settings, like statuses/types/fields/tags: the sweep reads + the ROOT's columns and acts on `root_project_id`, so a child's value would + be inert — the API keeps the inert case unreachable.""" + root = db.seed_project(name="Delivery") + child = db.seed_project(name="Sub", parent=str(root.id)) + with pytest.raises(HTTPException) as err: + run(tree.patch_node( + str(child.id), ProjectIn(close_after_months=2), user=USER, + )) + assert err.value.status_code == 422 + assert "root" in str(err.value.detail).lower() + + +def test_a_child_cannot_be_created_with_a_policy_either(db: FakeProjectsDB) -> None: + root = db.seed_project(name="Delivery") + with pytest.raises(HTTPException) as err: + run(tree.create_node( + ProjectIn( + name="Sub", parent_project_id=str(root.id), + archive_after_months=1, + ), + user=USER, + )) + assert err.value.status_code == 422 + + +def test_a_root_can_be_created_with_its_policy(db: FakeProjectsDB) -> None: + out = run(tree.create_node( + ProjectIn(name="Ops", archive_after_months=2, timezone="UTC"), + user=USER, + )) + assert out["archive_after_months"] == 2 + + +def test_the_validator_refuses_a_boolean_month() -> None: + """`True` is an `int` to isinstance and not to a person; the validator must + not let a truthy flag masquerade as one month.""" + with pytest.raises(HTTPException): + validate_lifecycle_settings({"archive_after_months": True}) + + +# ── 3 · the sweep ─────────────────────────────────────────────────────────── + +NOW = datetime(2026, 8, 9, 12, 0, tzinfo=UTC) +ACTOR = workflow_actor("wf-sweep") + + +def _months_ago(days: int) -> datetime: + return NOW - timedelta(days=days) + + +@pytest.fixture() +def swept(db: FakeProjectsDB): + """A root with every lane category and a policy switched ON.""" + proj = db.seed_project( + name="Delivery", archive_after_months=1, close_after_months=2, + ) + todo = db.seed_status(proj.id, name="To do", category="todo", position=10) + doing = db.seed_status( + proj.id, name="Doing", category="in_progress", is_default=False, + position=20, + ) + done = db.seed_status( + proj.id, name="Shipped", category="done", is_default=False, position=30, + ) + cancelled = db.seed_status( + proj.id, name="Abandoned", category="cancelled", is_default=False, + position=40, + ) + triage = db.seed_status( + proj.id, name="Front door", category="triage", is_default=False, + position=50, + ) + return SimpleNamespace( + project=proj, todo=todo, doing=doing, done=done, cancelled=cancelled, + triage=triage, + ) + + +def test_an_old_closed_task_is_archived_with_an_automation_activity( + db: FakeProjectsDB, swept, +) -> None: + old = db.seed_task( + swept.project.id, swept.done.id, title="Shipped long ago", + updated_at=_months_ago(60), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out["archived"] == 1 and out["projects"] == 1 + row = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(old.id)) + assert row["archived_at"] is not None + entry = db.activities("system")[-1] + assert entry["created_by"] == ACTOR + assert entry["meta"]["automation"] is True + assert "archived" in str(entry["body"]).lower() + + +def test_a_recently_touched_closed_task_is_left_alone( + db: FakeProjectsDB, swept, +) -> None: + db.seed_task( + swept.project.id, swept.done.id, title="Fresh", + updated_at=_months_ago(5), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out["archived"] == 0 and out["skipped"] is True + + +def test_an_old_open_task_is_closed_not_archived( + db: FakeProjectsDB, swept, +) -> None: + """The WS-27w archive guard, held by construction: the archive pass's + candidate lanes are the closed categories and nothing else, so a stale + OPEN task can only be closed — and only through the ordinary transition, + which stamps `completed_at` and writes the `status_change` row.""" + stale = db.seed_task( + swept.project.id, swept.doing.id, title="Stalled", + updated_at=_months_ago(90), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out["closed"] == 1 and out["archived"] == 0 + row = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(stale.id)) + assert row["archived_at"] is None + # Cancelled preferred over done: untouched-for-months work was abandoned, + # not finished, and calling it done would inflate every completion report. + assert str(row["status_id"]) == str(swept.cancelled.id) + assert row["completed_at"] is not None + move = db.activities("status_change")[-1] + assert move["created_by"] == ACTOR + assert move["meta"]["automation"] is True + assert move["meta"]["to_category"] == "cancelled" + + +def test_close_falls_back_to_done_when_no_cancelled_lane( + db: FakeProjectsDB, +) -> None: + proj = db.seed_project(name="NoCancel", close_after_months=1) + db.seed_status(proj.id, name="To do", category="todo", position=10) + done = db.seed_status( + proj.id, name="Done", category="done", is_default=False, position=20, + ) + stale = db.seed_task(proj.id, db.rows("pm_task_statuses")[0]["id"], + title="Old", updated_at=_months_ago(90)) + run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + row = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(stale.id)) + assert str(row["status_id"]) == str(done.id) + + +def test_triage_tasks_are_exempt_from_both_passes( + db: FakeProjectsDB, swept, +) -> None: + """WS-27u's queue is a place work WAITS for a human ruling; a sweeper that + closed or archived it would be the automation making the ruling.""" + parked = db.seed_task( + swept.project.id, swept.triage.id, title="Waiting at the door", + updated_at=_months_ago(400), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out["archived"] == 0 and out["closed"] == 0 + row = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(parked.id)) + assert row["archived_at"] is None + assert str(row["status_id"]) == str(swept.triage.id) + + +def test_the_sweep_is_idempotent(db: FakeProjectsDB, swept) -> None: + db.seed_task( + swept.project.id, swept.done.id, title="Old done", + updated_at=_months_ago(60), + ) + db.seed_task( + swept.project.id, swept.todo.id, title="Old open", + updated_at=_months_ago(90), + ) + first = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert first["archived"] == 1 and first["closed"] == 1 + rows_before = len(db.rows("pm_activities")) + second = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert second == { + "projects": 1, "archived": 0, "closed": 0, "skipped": True, + } + assert len(db.rows("pm_activities")) == rows_before + + +def test_a_project_with_no_policy_is_never_touched(db: FakeProjectsDB) -> None: + proj = db.seed_project(name="Untended") # both columns NULL — the default + status = db.seed_status(proj.id, name="Done", category="done") + db.seed_task(proj.id, status.id, title="Ancient", + updated_at=_months_ago(1000)) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out == {"projects": 0, "archived": 0, "closed": 0, "skipped": True} + + +def test_subprojects_inherit_the_roots_policy(db: FakeProjectsDB, swept) -> None: + """Per-project means per ROOT project: the sweep selects on + `root_project_id`, so a task in a subproject is governed by the root's + columns — the same way it wears the root's statuses.""" + child = db.seed_project(name="Sub", parent=str(swept.project.id)) + old = db.seed_task( + str(child.id), swept.done.id, root=str(swept.project.id), + title="Done, in the sub", updated_at=_months_ago(60), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=NOW)) + assert out["archived"] == 1 + row = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(old.id)) + assert row["archived_at"] is not None + + +def test_the_projects_midnight_wins_over_utcs(db: FakeProjectsDB) -> None: + """The P-28 point, as a fixture where the two midnights DISAGREE. + + At 2026-08-09T02:00Z it is still 2026-08-08 in Los Angeles. A one-month + window measured from the UTC midnight (2026-08-09T00:00Z → cutoff + 2026-07-09T00:00Z) would archive a task last touched 2026-07-08T12:00Z; + measured from the project's own midnight (2026-08-08T00:00-07:00 → cutoff + 2026-07-08T07:00Z) it is INSIDE the window and must be kept. + """ + early = datetime(2026, 8, 9, 2, 0, tzinfo=UTC) + proj = db.seed_project( + name="West coast", archive_after_months=1, + timezone="America/Los_Angeles", + ) + done = db.seed_status(proj.id, name="Done", category="done") + kept = db.seed_task( + proj.id, done.id, title="Inside the LA window", + updated_at=datetime(2026, 7, 8, 12, 0, tzinfo=UTC), + ) + swept_task = db.seed_task( + proj.id, done.id, title="Outside even the LA window", + updated_at=datetime(2026, 7, 8, 6, 0, tzinfo=UTC), + ) + out = run(run_lifecycle_sweep(db, actor=ACTOR, now=early)) + assert out["archived"] == 1 + rows = {str(r["id"]): r for r in db.rows("pm_tasks")} + assert rows[str(kept.id)]["archived_at"] is None + assert rows[str(swept_task.id)]["archived_at"] is not None + + +# ── 4 · the automation flag ───────────────────────────────────────────────── + +@pytest.fixture() +def patched(db: FakeProjectsDB): + proj = db.seed_project(name="Delivery") + todo = db.seed_status(proj.id, name="To do", category="todo") + done = db.seed_status( + proj.id, name="Done", category="done", is_default=False, position=20, + ) + task = db.seed_task(proj.id, todo.id, title="A task") + return SimpleNamespace(project=proj, todo=todo, done=done, task=task) + + +def test_an_engine_field_edit_is_flagged_automation(db, patched) -> None: + run(apply_task_patch( + db, str(patched.task.id), {"title": "Renamed"}, + actor=workflow_actor("wf-1"), + )) + entry = db.activities("field_change")[0] + assert entry["meta"]["automation"] is True + + +def test_an_engine_status_move_is_flagged_automation(db, patched) -> None: + run(apply_task_patch( + db, str(patched.task.id), {"status": "Done"}, + actor=workflow_actor("wf-1"), + )) + entry = db.activities("status_change")[0] + assert entry["meta"]["automation"] is True + # The flag rides BESIDE the transition's own meta, replacing nothing. + assert entry["meta"]["from"] == "To do" and entry["meta"]["to"] == "Done" + + +def test_a_human_field_edit_carries_no_flag(db, patched) -> None: + """Not `automation: false` — no key at all. Every pre-WS-27z row lacks it, + and a consumer must read absence and false as the same thing.""" + run(record_field_change( + db, created_by="owner@fracktal.in", task_id=str(patched.task.id), + changes=[{"field": "title", "old": "A task", "new": "Renamed"}], + )) + entry = db.activities("field_change")[0] + assert "automation" not in entry["meta"] + + +def test_a_human_status_move_carries_no_flag(db, patched) -> None: + task = next( + SimpleNamespace(**r) for r in db.rows("pm_tasks") + if str(r["id"]) == str(patched.task.id) + ) + run(apply_status_transition( + db, task, str(patched.done.id), created_by="owner@fracktal.in", + )) + entry = db.activities("status_change")[0] + assert "automation" not in entry["meta"] + + +def test_automated_description_edits_still_coalesce(db, patched) -> None: + """The WS-27f no-spam rule survives the flag: a workflow rewriting a + description every run folds into one row — flag intact — instead of the + flag's presence in meta breaking the WS-27w coalescing check.""" + actor = workflow_actor("wf-1") + run(apply_task_patch(db, str(patched.task.id), {"description": "v1"}, actor=actor)) + run(apply_task_patch(db, str(patched.task.id), {"description": "v2"}, actor=actor)) + entries = db.activities("field_change") + assert len(entries) == 1 + assert entries[0]["meta"]["automation"] is True + assert entries[0]["meta"]["changes"][0]["new"] == "v2" + + +def test_a_human_edit_never_folds_into_an_automated_row(db, patched) -> None: + """Same actor string, different species: the flag is part of what the row + asserts, so folding across it would forge history in either direction.""" + run(record_field_change( + db, created_by="x", task_id=str(patched.task.id), + changes=[{"field": "description", "old": None, "new": "v1"}], + automation=True, + )) + run(record_field_change( + db, created_by="x", task_id=str(patched.task.id), + changes=[{"field": "description", "old": "v1", "new": "v2"}], + )) + entries = db.activities("field_change") + assert len(entries) == 2 + + +# ── 5 · the engine wiring ─────────────────────────────────────────────────── + +def _graph() -> dict: + return { + "nodes": [ + {"id": "t", "type": "trigger", "data": {"config": {}}}, + {"id": "sweep", "type": "pm_lifecycle", "data": {"config": {}}}, + ], + "edges": [{"id": "e", "source": "t", "target": "sweep"}], + } + + +def test_the_sweep_node_type_exists_and_publishes_config_free() -> None: + """The workflow supplies nothing but the trigger and the node — ALL policy + lives in the pm_projects columns, so an empty config is a publishable + graph rather than a missing_config.""" + assert "pm_lifecycle" in NODE_TYPES + assert validate_graph(_graph()) == [] + compiled = compile_graph(_graph()) + assert any(b["type"] == "pm_lifecycle" for b in compiled["blocks"]) + + +def test_the_catalog_serves_the_node(monkeypatch) -> None: + entry = next( + (m for m in NODE_TYPE_META if m["type"] == "pm_lifecycle"), None, + ) + assert entry is not None + assert entry["category"] == "action" + + +def test_the_node_has_a_timeout_budget() -> None: + assert NODE_TIMEOUTS["pm_lifecycle"] > NODE_TIMEOUTS["pm_task"] + + +def _services(**over) -> NodeServices: + async def _unused(*a, **k): # pragma: no cover — wrong seam + raise AssertionError("wrong seam") + + return NodeServices( + run_agent=_unused, run_tool=_unused, get_module_code=_unused, + actor="workflow:test", **over, + ) + + +def test_the_handler_calls_the_injected_sweep() -> None: + async def _sweep() -> dict: + return {"projects": 2, "archived": 1, "closed": 0, "skipped": False} + + out = run(execute_node( + {"type": "pm_lifecycle", "config": {}}, {}, + _services(run_lifecycle_sweep=_sweep), + )) + assert out["archived"] == 1 + + +def test_the_handler_fails_loudly_when_projects_is_not_wired() -> None: + with pytest.raises(NodeExecutionError): + run(execute_node({"type": "pm_lifecycle", "config": {}}, {}, _services())) + + +def test_the_service_binding_sweeps_as_the_workflow_and_commits( + monkeypatch, +) -> None: + """`_pm_lifecycle_sweeper` mirrors `_pm_task_updater`: closure import, the + workflow's identity bound in, one commit around the whole sweep.""" + from gateway.routes.workflows import service as wf_service + + fake = FakeProjectsDB() + proj = fake.seed_project(name="Delivery", archive_after_months=1) + done = fake.seed_status(proj.id, name="Done", category="done") + fake.seed_task(proj.id, done.id, title="Old", + updated_at=datetime.now(UTC) - timedelta(days=90)) + + async def _get_db(): + return fake + + monkeypatch.setattr(wf_service, "_get_db", _get_db) + result = run(wf_service._pm_lifecycle_sweeper("wf-9")()) + assert result["archived"] == 1 + assert fake.committed == 1 + assert fake.closed is True + entry = fake.activities("system")[-1] + assert entry["created_by"] == "system:workflow:wf-9" + assert entry["meta"]["automation"] is True + + +def test_build_node_services_carries_the_sweep_seam() -> None: + from gateway.routes.workflows.service import build_node_services + + services = build_node_services("workflow", "wf-1") + assert services.run_lifecycle_sweep is not None + assert services.update_task is not None diff --git a/tests/unit/test_projects_notifications.py b/tests/unit/test_projects_notifications.py index 586b8f94e..2516dcb76 100644 --- a/tests/unit/test_projects_notifications.py +++ b/tests/unit/test_projects_notifications.py @@ -219,6 +219,10 @@ async def execute(self, sql: Any, params: dict | None = None) -> _Result: who = args.get("vis_email") seen = who is None or who in self.visible_to return _Result([SimpleNamespace(x=1)] if seen else []) + # WS-27v's split badge count — one query, two aggregates, so the two + # numbers can never describe different row sets. + if "FILTER (WHERE n.kind = 'mention')" in statement: + return _Result([SimpleNamespace(total=3, mentions=1)]) if "assignee AS who" in statement: return _Result([SimpleNamespace(who=w) for w in self.audience]) if "UPDATE pm_notifications" in statement: @@ -415,6 +419,23 @@ def test_the_badge_count_cannot_promise_more_than_the_list_shows(monkeypatch): assert "read_at IS NULL" in counted +def test_the_unread_count_is_split_into_total_and_mentions(monkeypatch): + """WS-27v. `{total, mentions}` rather than a bare number: "you were named" + is a stronger claim on attention than "something you follow moved", and the + bell draws the two distinctly. Counted in ONE query with a FILTER, through + the same visibility clause and unread predicate as before, so mentions is a + subset of total by construction.""" + db = FakeDB() + bind(monkeypatch, db, pm_notify, pm_core) + result = run(pm_notify.list_notifications(user=ACTOR, page=page())) + assert result["unread"] == {"total": 3, "mentions": 1} + counted = next( + s for s in db.statements if "FILTER (WHERE n.kind = 'mention')" in s + ) + assert "pm_project_grants" in counted + assert "read_at IS NULL" in counted + + def test_unread_only_narrows_the_list(monkeypatch): db = FakeDB() bind(monkeypatch, db, pm_notify, pm_core) diff --git a/tests/unit/test_projects_routes.py b/tests/unit/test_projects_routes.py index 53d94f24d..b6a57ffaf 100644 --- a/tests/unit/test_projects_routes.py +++ b/tests/unit/test_projects_routes.py @@ -81,6 +81,8 @@ def test_every_feature_module_is_actually_mounted() -> None: "/projects/nodes/{project_id}/grants", "/projects/tasks", "/projects/tasks/{task_id}", + "/projects/tasks/{task_id}/archive", + "/projects/tasks/{task_id}/unarchive", "/projects/tasks/{task_id}/assignees", "/projects/tasks/{task_id}/timeline", "/projects/tasks/{task_id}/comments", @@ -389,13 +391,15 @@ async def test_every_allowlisted_sort_key_reaches_the_order_by( db: FakeProjectsDB, key: str, ) -> None: """The allowlist's values are the only identifiers interpolated into the - ORDER BY, so this also pins that none of them is caller text.""" + ORDER BY, so this also pins that none of them is caller text. The values + are `{dir}` templates since WS-27w; the default direction is `desc`.""" _project_with_statuses(db) await pm_tasks.list_tasks(user=USER, sort=key, page=page()) ordered = [s for s in db.statements if "ORDER BY" in s] - assert any(pm_core.TASK_SORTS[key] in s for s in ordered) + expected = " ".join(pm_core.TASK_SORTS[key].format(dir="DESC").split()) + assert any(expected in s for s in ordered) async def test_archived_tasks_are_hidden_by_default(db: FakeProjectsDB) -> None: diff --git a/tests/unit/test_projects_watchers.py b/tests/unit/test_projects_watchers.py new file mode 100644 index 000000000..bdbef9975 --- /dev/null +++ b/tests/unit/test_projects_watchers.py @@ -0,0 +1,449 @@ +"""WS-27v — watchers, and mentions that behave. + +Spec: `ai-company-brain/specs/project_management_app.md` §9.1 (WS-27v), from +`plane_pm_research_2026-08.md` §3.2 (P-2, P-20). + +Four claims, each of which is the ticket: + +* **auto-subscribe is idempotent, from every trigger** — commenting, editing, + being assigned, being @mentioned. One helper (`ensure_watchers`), one UNIQUE + pair, so the fourth comment does not stack a fourth row; +* **the audience is watchers ∪ assignees, minus the actor, gated by the + RECIPIENT'S visibility** — Plane's membership-only check is the + counterexample: a watcher row is an intent to hear, never a right to see, + and `resolve_visibility_for` stays the gate; +* **mention diffing** — editing a comment or description notifies only the + NEWLY added mentions, proven by editing one comment twice; +* **watch/unwatch answer R5** — an invisible task is 404, never 403. + +Run against the shared `_projects_fakes.FakeProjectsDB` so the real route +functions execute end to end: the fake mirrors the audience UNION and the +visibility probe off the statement text, so a route that loses either arm +loses it here too. Hermetic: no Postgres, no TestClient. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import activities as pm_activities +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import notifications as pm_notify +from gateway.routes.projects import tasks as pm_tasks +from gateway.routes.projects import watchers as pm_watchers +from gateway.routes.projects.notifications import new_mentions +from gateway.routes.projects.watchers import watchable + +from tests.unit._projects_fakes import ( + FakeProjectsDB, + bind_db, + member_user, + projects_user, + silence_events, +) + +MODULES = (pm_core, pm_tasks, pm_activities, pm_notify, pm_watchers) +USER = projects_user() +MIGRATIONS = Path(__file__).resolve().parents[2] / "infra" / "postgres" +PACKAGE = Path(pm_watchers.__file__).parent + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + silence_events(monkeypatch, MODULES) + return fake + + +def _task(db: FakeProjectsDB, *, subject: str | None = "org"): + project = db.seed_project(name="Ops", subject=subject) + todo = db.seed_status(project.id, name="To do", category="todo") + task = db.seed_task(project.id, todo.id) + return project, todo, task + + +def _watchers(db: FakeProjectsDB, task_id: str) -> list[str]: + return sorted( + r["watcher"] for r in db.rows("pm_task_watchers") + if str(r["task_id"]) == str(task_id) + ) + + +def _notified(db: FakeProjectsDB, kind: str | None = None) -> list[str]: + return [ + str(r["recipient"]) for r in db.rows("pm_notifications") + if kind is None or r["kind"] == kind + ] + + +# ── The pure half ─────────────────────────────────────────────────────────── + +def test_watchable_folds_dedupes_and_drops_agents_and_blanks(): + """R10 on the way in, and WS-27j's rule 2: an agent subscription would feed + an inbox nobody ever opens.""" + assert watchable( + ["Priya@Fracktal.IN", "priya@fracktal.in", "agent:builder", " ", None, + "not-an-address"], + ) == ["priya@fracktal.in"] + + +def test_new_mentions_is_the_set_difference_in_first_seen_order(): + assert new_mentions( + "ping @a@x.co", "ping @a@x.co then @c@x.co and @b@x.co", + ) == ["c@x.co", "b@x.co"] + + +def test_new_mentions_of_a_first_body_is_every_mention(): + assert new_mentions(None, "hey @a@x.co") == ["a@x.co"] + + +def test_a_removed_then_retyped_mention_is_not_new(): + """Case-insensitively (R10): re-spelling `@A@X.CO` is the same person.""" + assert new_mentions("hi @a@x.co", "hi @A@X.CO") == [] + + +# ── Auto-subscribe: all four triggers, each idempotent ────────────────────── + +async def test_commenting_subscribes_the_commenter_once(db: FakeProjectsDB): + _, _, task = _task(db) + for _ in range(2): + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="on it"), user=USER, + ) + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_editing_subscribes_the_editor_once(db: FakeProjectsDB): + _, _, task = _task(db) + for title in ("First", "Second"): + await pm_tasks.patch_task( + str(task.id), pm_tasks.TaskIn(title=title), user=USER, + ) + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_a_noop_patch_is_not_a_touch(db: FakeProjectsDB): + _, _, task = _task(db) + await pm_tasks.patch_task(str(task.id), pm_tasks.TaskIn(), user=USER) + assert _watchers(db, task.id) == [] + + +async def test_an_added_assignee_is_subscribed_once_and_agents_never( + db: FakeProjectsDB, +): + _, _, task = _task(db) + for _ in range(2): + await pm_tasks.set_assignees( + str(task.id), + pm_tasks.AssigneesIn( + assignees=["priya@fracktal.in", "agent:builder"], + ), + user=USER, + ) + assert _watchers(db, task.id) == ["priya@fracktal.in"] + + +async def test_a_delivered_mention_subscribes_its_target_once( + db: FakeProjectsDB, +): + _, _, task = _task(db) + for _ in range(2): + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="see @priya@fracktal.in"), + user=USER, + ) + assert "priya@fracktal.in" in _watchers(db, task.id) + assert _watchers(db, task.id).count("priya@fracktal.in") == 1 + + +async def test_an_undeliverable_mention_subscribes_nobody(db: FakeProjectsDB): + """A person who cannot open the task must not be enrolled in a stream of + its titles — the subscription follows the delivery, and the delivery is + gated by the recipient's own visibility.""" + _, _, task = _task(db, subject="owner@fracktal.in") + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="see @outsider@fracktal.in"), + user=USER, + ) + assert "outsider@fracktal.in" not in _watchers(db, task.id) + assert _notified(db, "mention") == [] + + +async def test_the_creator_watches_their_own_task(db: FakeProjectsDB): + """What keeps WS-27j's author-hears-about-comments once the audience is + watchers ∪ assignees; migration 165 seeds the same for existing tasks.""" + project, _, _ = _task(db) + created = await pm_tasks.create_task( + pm_tasks.TaskIn(project_id=str(project.id), title="Mine"), user=USER, + ) + assert _watchers(db, created["id"]) == ["owner@fracktal.in"] + + +# ── Watch / unwatch ───────────────────────────────────────────────────────── + +async def test_watch_is_idempotent_and_folded(db: FakeProjectsDB): + _, _, task = _task(db) + caller = projects_user(email="Owner@Fracktal.IN") + for _ in range(2): + result = await pm_watchers.watch_task(str(task.id), user=caller) + assert result == {"task_id": str(task.id), "watching": True} + # R10: stored folded, or the unwatch and the fan-out would both miss it. + assert _watchers(db, task.id) == ["owner@fracktal.in"] + + +async def test_unwatch_removes_only_the_caller(db: FakeProjectsDB): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="other@fracktal.in") + await pm_watchers.watch_task(str(task.id), user=USER) + result = await pm_watchers.unwatch_task(str(task.id), user=USER) + assert result == {"task_id": str(task.id), "watching": False} + assert _watchers(db, task.id) == ["other@fracktal.in"] + + +async def test_the_watchers_read_names_the_callers_own_state( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="owner@fracktal.in") + result = await pm_watchers.list_watchers(str(task.id), user=USER) + assert result == {"watchers": ["owner@fracktal.in"], "watching": True} + + +@pytest.mark.parametrize( + "route", [pm_watchers.watch_task, pm_watchers.unwatch_task, + pm_watchers.list_watchers], +) +async def test_an_invisible_task_is_404_never_403(db: FakeProjectsDB, route): + """R5. "not yours" and "no such task" must be one answer, or the endpoint + becomes an oracle for which ids exist in another department.""" + _, _, task = _task(db, subject=None) + with pytest.raises(HTTPException) as exc: + await route(str(task.id), user=member_user("nobody@fracktal.in")) + assert exc.value.status_code == 404 + + +def test_the_identity_comes_from_the_session_not_a_parameter(): + """R3 — a `watcher` parameter would let anyone subscribe anyone else to a + stream of a task's titles.""" + import inspect + + for route in (pm_watchers.watch_task, pm_watchers.unwatch_task): + assert "watcher" not in set(inspect.signature(route).parameters) + + +# ── The audience: watchers ∪ assignees, minus the actor, gated per person ─── + +async def test_a_comment_reaches_watchers_and_assignees_but_never_the_actor( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="watcher@fracktal.in") + db.seed( + "pm_task_assignees", task_id=task.id, assignee="holder@fracktal.in", + assigned_by="owner@fracktal.in", + ) + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="no names here"), user=USER, + ) + # The commenter auto-subscribed above and is STILL not notified — the + # actor-exclusion rule re-asserted over the new audience. + assert sorted(_notified(db, "comment")) == [ + "holder@fracktal.in", "watcher@fracktal.in", + ] + + +async def test_a_watcher_who_lost_visibility_is_not_notified( + db: FakeProjectsDB, +): + """The deliberate divergence from Plane: the watcher row survives, the + delivery does not. `resolve_visibility_for` is the gate, per recipient.""" + _, _, task = _task(db, subject="insider@fracktal.in") + db.seed("pm_task_watchers", task_id=task.id, watcher="insider@fracktal.in") + db.seed("pm_task_watchers", task_id=task.id, watcher="gone@fracktal.in") + await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="quarterly update"), + user=USER, + ) + assert _notified(db, "comment") == ["insider@fracktal.in"] + # The row is intact: visibility is checked at delivery, never persisted. + assert "gone@fracktal.in" in _watchers(db, task.id) + + +async def test_a_mentioned_watcher_gets_the_mention_not_the_comment_too( + db: FakeProjectsDB, +): + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="priya@fracktal.in") + await pm_activities.add_comment( + str(task.id), + pm_activities.CommentIn(body="over to @priya@fracktal.in"), + user=USER, + ) + mine = [ + r["kind"] for r in db.rows("pm_notifications") + if r["recipient"] == "priya@fracktal.in" + ] + assert mine == ["mention"] + + +def test_the_audience_is_the_one_helper_not_a_second_query(): + """`task_audience` is the single place "who hears" is derived; a second + UNION grown beside it is how the arms start disagreeing.""" + source = Path(pm_notify.__file__).read_text(encoding="utf-8") + body = source.split("async def task_audience", 1)[1] + body = body.split("\nasync def ", 1)[0].split("\n# ──", 1)[0] + assert "pm_task_watchers" in body + assert "pm_task_assignees" in body + + +# ── Mention diffing: the edit-twice proof ─────────────────────────────────── + +async def test_editing_a_comment_twice_notifies_only_the_new_mention( + db: FakeProjectsDB, +): + """THE ticket's test. First edit adds @b — b is notified. Second edit keeps + @b and adds @c — ONLY c is notified, or every typo fix re-pings the whole + thread.""" + _, _, task = _task(db) + posted = await pm_activities.add_comment( + str(task.id), pm_activities.CommentIn(body="draft"), user=USER, + ) + await pm_activities.edit_comment( + posted["id"], + pm_activities.CommentIn(body="draft @b@fracktal.in"), + user=USER, + ) + assert _notified(db, "mention") == ["b@fracktal.in"] + + await pm_activities.edit_comment( + posted["id"], + pm_activities.CommentIn(body="draft @b@fracktal.in @c@fracktal.in"), + user=USER, + ) + # One NEW row, addressed to c alone — b was not re-notified. + assert _notified(db, "mention") == ["b@fracktal.in", "c@fracktal.in"] + # And both delivered mentions became watchers, once each. + assert set(_watchers(db, task.id)) >= {"b@fracktal.in", "c@fracktal.in"} + + +async def test_a_description_edit_notifies_only_added_mentions( + db: FakeProjectsDB, +): + """Same diff on the task body, and NOTHING else: watchers at large hear + nothing about a description edit — the diffed mentions are the fan-out.""" + _, _, task = _task(db) + db.seed("pm_task_watchers", task_id=task.id, watcher="watcher@fracktal.in") + await pm_tasks.patch_task( + str(task.id), + pm_tasks.TaskIn(description="scope for @b@fracktal.in"), user=USER, + ) + assert _notified(db) == ["b@fracktal.in"] + + await pm_tasks.patch_task( + str(task.id), + pm_tasks.TaskIn( + description="scope for @b@fracktal.in and @c@fracktal.in", + ), + user=USER, + ) + assert _notified(db) == ["b@fracktal.in", "c@fracktal.in"] + assert _notified(db, "comment") == [] + + +# ── One helper, one INSERT site ───────────────────────────────────────────── + +def test_every_subscription_goes_through_ensure_watchers(): + """The auto-subscribe rule lives once. A copy-pasted INSERT would casefold + or fence agents differently the day one of them is edited alone.""" + inserting = [ + path.name for path in sorted(PACKAGE.glob("*.py")) + if "INSERT INTO pm_task_watchers" in path.read_text(encoding="utf-8") + ] + assert inserting == ["watchers.py"] + for module in ("tasks.py", "activities.py"): + assert "ensure_watchers(" in (PACKAGE / module).read_text( + encoding="utf-8", + ) + + +# ── Migration 165, read as text (R1: found by content, never by number) ───── + +def _watchers_migration() -> Path: + found = [ + path for path in sorted(MIGRATIONS.glob("*.sql")) + if path.name != "schema.generated.sql" + and "CREATE TABLE IF NOT EXISTS pm_task_watchers" + in path.read_text(encoding="utf-8") + ] + assert len(found) == 1, ( + f"expected exactly one migration creating pm_task_watchers, found " + f"{[p.name for p in found]}" + ) + return found[0] + + +@pytest.fixture(scope="module") +def sql() -> str: + raw = _watchers_migration().read_text(encoding="utf-8") + # Comments stripped: an assertion the header's prose could satisfy is an + # assertion that fails open. + return "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + + +def test_the_table_is_guarded_and_unique_per_pair(sql: str): + assert "CREATE TABLE IF NOT EXISTS pm_task_watchers" in sql + assert re.search(r"UNIQUE\s*\(\s*task_id\s*,\s*watcher\s*\)", sql, re.I) + + +def test_a_watcher_is_stored_folded(sql: str): + """R10 — every read compares folded; a mixed-case row is a subscription + that never fires.""" + assert re.search(r"watcher\s*=\s*lower\s*\(\s*watcher\s*\)", sql) + + +def test_an_agent_cannot_be_a_watcher(sql: str): + assert re.search(r"watcher\s+NOT\s+LIKE\s+'agent:%'", sql) + + +def test_a_deleted_task_takes_its_watchers_with_it(sql: str): + block = sql.split("CREATE TABLE IF NOT EXISTS pm_task_watchers", 1)[1] + block = block.split(");", 1)[0] + assert re.search( + r"task_id\s+UUID\s+NOT\s+NULL\s+REFERENCES\s+pm_tasks\s*\(id\)" + r"\s+ON\s+DELETE\s+CASCADE", + block, re.I, + ) + + +def test_the_tenant_key_is_carried_and_derived(sql: str): + """D-MT-3 (the column) and migration 161's trigger (the fill-and-verify), + attached exactly as the other pm_* tables attach it.""" + block = sql.split("CREATE TABLE IF NOT EXISTS pm_task_watchers", 1)[1] + block = block.split(");", 1)[0] + assert re.search( + r"organization_id\s+UUID\s+NOT\s+NULL\s+REFERENCES\s+organization" + r"\s*\(id\)\s+ON\s+DELETE\s+CASCADE", + block, re.I, + ) + assert re.search( + r"CREATE OR REPLACE TRIGGER \S+\s+BEFORE INSERT OR UPDATE ON " + r"pm_task_watchers", + sql, + ) + assert "pm_organization_from_parent('pm_tasks', 'task_id')" in sql + + +def test_the_seed_replays_as_a_noop_and_seeds_only_humans(sql: str): + """The runner replays every migration on every deploy; and the seeded + authors must satisfy the table's own CHECKs or the second deploy dies.""" + seed = re.search(r"INSERT\s+INTO\s+pm_task_watchers(.*?);", sql, re.S) + assert seed is not None + assert re.search(r"ON\s+CONFLICT\s*\(\s*task_id\s*,\s*watcher\s*\)", seed.group(1)) + assert "NOT LIKE 'agent:%'" in seed.group(1) + assert "lower(t.created_by)" in seed.group(1) diff --git a/workbench/control_plane/src/app/projects/components/CalendarView.tsx b/workbench/control_plane/src/app/projects/components/CalendarView.tsx index 6a6e5bac2..63e8e1e15 100644 --- a/workbench/control_plane/src/app/projects/components/CalendarView.tsx +++ b/workbench/control_plane/src/app/projects/components/CalendarView.tsx @@ -20,6 +20,7 @@ import { TaskMeta } from "@/components/TaskMeta"; import Button from "@/components/ui/Button"; import type { TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; import { type MonthGrid, isOutsideMonth, @@ -27,7 +28,10 @@ import { placeTasks, rescheduleTo, } from "../lib/calendar"; -import { cardChips } from "../lib/card"; +import { visibleChips } from "../lib/card"; +import { quickAddPrefill } from "../lib/quickAdd"; +import { QuickAdd } from "./QuickAdd"; +import { useFlash } from "./useFlash"; const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; @@ -39,6 +43,11 @@ interface Props { /** The window hit the server's cap; some tasks are missing. */ truncated: boolean; today?: string; + /** WS-27y — where a day's quick-added task is created (the selected node). */ + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; + projectId: string; + onCreated: (task: TaskRow) => void; onSelect: (task: TaskRow) => void; onMove: (task: TaskRow, patch: Record) => void; onStep: (months: number) => void; @@ -51,12 +60,29 @@ export function CalendarView({ undated, truncated, today, + projectId, + shownFields, + onCreated, onSelect, onMove, onStep, onToday, }: Props) { const byDay = placeTasks(tasks, grid); + const { flash, attach } = useFlash(); + + /** WS-27y — a title typed into a day creates a task DUE that day, through + * the same axis→payload mapping every other quick-add uses. */ + async function quickAdd(title: string, day: string) { + const plan = quickAddPrefill("day", day); + const created = await projectsApi.createTask({ + project_id: projectId, + title, + ...plan.create, + }); + flash(created.id); + onCreated(created); + } return (
@@ -117,7 +143,12 @@ export function CalendarView({ // so a task dropped back on its own day writes nothing rather // than posting an activity saying it moved to where it was. const patch = rescheduleTo(task, day); - if (patch) onMove(task, patch as Record); + if (patch) { + // The landing flash finds the card after the month reloads + // and re-keys it under its new day (WS-27y). + flash(task.id); + onMove(task, patch as Record); + } }} className={`min-h-24 bg-card p-1 ${outside ? "opacity-50" : ""}`} > @@ -134,7 +165,7 @@ export function CalendarView({
    {(byDay.get(day) ?? []).map((task) => ( -
  • +
  • ))}
+ {/* WS-27y — a title typed here is due THIS day. */} + quickAdd(title, day)} + className="mt-1" + /> ); })} diff --git a/workbench/control_plane/src/app/projects/components/FilterBar.tsx b/workbench/control_plane/src/app/projects/components/FilterBar.tsx index 158e0bced..b83d784f7 100644 --- a/workbench/control_plane/src/app/projects/components/FilterBar.tsx +++ b/workbench/control_plane/src/app/projects/components/FilterBar.tsx @@ -22,7 +22,7 @@ import Button from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { useEffect, useState } from "react"; -import type { TagRow, ViewRow } from "../lib/api"; +import type { FieldRow, TagRow, ViewRow } from "../lib/api"; import { EMPTY_FILTERS, type Filters, @@ -30,6 +30,14 @@ import { type GroupBy, isFiltered, } from "../lib/grouping"; +import { + DEFAULT_SHOWN, + FIELD_KEYS, + FIELD_LABELS, + customFieldKey, + sameFieldSet, + toggleField, +} from "../lib/shownFields"; import { byUsage, chipClass } from "../lib/tags"; /** The status categories, labelled. Mirrors the gateway's `STATUS_CATEGORIES`. */ @@ -60,10 +68,18 @@ interface Props { onFilters: (next: Filters) => void; groupBy: GroupBy; onGroupBy: (next: GroupBy) => void; + /** WS-27y — the board's second axis, drawn as swimlanes. `"none"` = flat. */ + subGroupBy: GroupBy; + onSubGroupBy: (next: GroupBy) => void; /** The signed-in member's address, for the "Mine" toggle. Empty while loading. */ me: string; /** WS-27m — the project's registered tags, for the tag row. */ tags: TagRow[]; + /** WS-27x — the view's shown fields: the table's columns AND the chip gate. */ + shownFields: readonly string[]; + onShownFields: (next: string[]) => void; + /** WS-27l — the project's custom field definitions, offered in the picker. */ + fields: FieldRow[]; views: ViewRow[]; activeViewId: string | null; onApplyView: (view: ViewRow) => void; @@ -78,8 +94,13 @@ export function FilterBar({ onFilters, groupBy, onGroupBy, + subGroupBy, + onSubGroupBy, me, tags, + shownFields, + onShownFields, + fields, views, activeViewId, onApplyView, @@ -93,6 +114,8 @@ export function FilterBar({ const [draft, setDraft] = useState(filters.q); const [naming, setNaming] = useState(false); const [viewName, setViewName] = useState(""); + // WS-27x — the shown-fields picker's popover. + const [pickingFields, setPickingFields] = useState(false); useEffect(() => setDraft(filters.q), [filters.q]); @@ -181,6 +204,86 @@ export function FilterBar({ + {/* WS-27y — the board's second axis. The main axis is withheld from + the options: a board laned by its own columns means nothing, and + `fromConfig` would normalise it away anyway. */} + + + {/* WS-27x — which fields this view shows. ONE set feeding two + consumers: the table's columns and every card's chip row, so + hiding a field here silences it everywhere at once. */} +
+ + {pickingFields ? ( +
{ + if (e.key === "Escape") setPickingFields(false); + }} + > +
+ + Shown fields + + +
+
+ {[ + ...FIELD_KEYS.map((key) => ({ key, label: FIELD_LABELS[key] })), + ...fields.map((def) => ({ + key: customFieldKey(def), + label: def.name, + })), + ].map(({ key, label }) => ( + + ))} +
+
+ ) : null} +
+ {isFiltered(filters) ? ( + + + + + ); +} diff --git a/workbench/control_plane/src/app/projects/components/NotificationBell.tsx b/workbench/control_plane/src/app/projects/components/NotificationBell.tsx index 52a13c6b3..e5e053808 100644 --- a/workbench/control_plane/src/app/projects/components/NotificationBell.tsx +++ b/workbench/control_plane/src/app/projects/components/NotificationBell.tsx @@ -19,11 +19,14 @@ import Button from "@/components/ui/Button"; import { type NotificationRow, notificationsApi } from "../lib/api"; import { + type UnreadSplit, + afterRead, badge, describe, linkTo, order, unreadIds, + unreadSplit, } from "../lib/notifications"; /** How often the badge re-checks while the tab is open. */ @@ -34,7 +37,7 @@ export function NotificationBell({ onOpenTask }: { onOpenTask?: (taskId: string) => void; }) { const [rows, setRows] = useState([]); - const [unread, setUnread] = useState(0); + const [unread, setUnread] = useState({ total: 0, mentions: 0 }); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const box = useRef(null); @@ -43,7 +46,7 @@ export function NotificationBell({ onOpenTask }: { try { const res = await notificationsApi.list(); setRows(order(res.rows)); - setUnread(res.unread); + setUnread(unreadSplit(res.unread)); setError(null); } catch (err) { setError((err as Error).message); @@ -87,7 +90,7 @@ export function NotificationBell({ onOpenTask }: { r.id === row.id ? { ...r, read_at: new Date().toISOString() } : r, ), ); - setUnread((n) => Math.max(0, n - 1)); + setUnread((u) => afterRead(u, row.kind)); try { await notificationsApi.markRead([row.id]); } catch { @@ -101,7 +104,7 @@ export function NotificationBell({ onOpenTask }: { const dismissAll = async () => { const ids = unreadIds(rows); if (!ids.length) return; - setUnread(0); + setUnread({ total: 0, mentions: 0 }); setRows((prev) => prev.map((r) => (r.read_at ? r : { ...r, read_at: new Date().toISOString() })), ); @@ -112,7 +115,8 @@ export function NotificationBell({ onOpenTask }: { } }; - const count = badge(unread); + const count = badge(unread.total); + const mentionCount = badge(unread.mentions); return (
@@ -120,7 +124,13 @@ export function NotificationBell({ onOpenTask }: { variant="ghost" size="icon-sm" icon="Bell" - aria-label={count ? `Notifications (${count} unread)` : "Notifications"} + aria-label={ + count + ? `Notifications (${count} unread${ + mentionCount ? `, ${mentionCount} mentions` : "" + })` + : "Notifications" + } onClick={() => setOpen((v) => !v)} /> {count ? ( @@ -131,6 +141,17 @@ export function NotificationBell({ onOpenTask }: { {count} ) : null} + {/* WS-27v: mentions get their own marker, below the total so the two + never overlap. `@` rather than a second number at this size — the + count itself is in the aria-label and the panel header. */} + {mentionCount ? ( + + @ + + ) : null} {open ? (
Notifications + {unread.mentions > 0 ? ( + + {badge(unread.mentions)} @ + + ) : null} - {unread > 0 ? ( + {unread.total > 0 ? ( diff --git a/workbench/control_plane/src/app/projects/components/QuickAdd.tsx b/workbench/control_plane/src/app/projects/components/QuickAdd.tsx new file mode 100644 index 000000000..3438f15ee --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/QuickAdd.tsx @@ -0,0 +1,9 @@ +/** + * Projects · the inline group-context add (WS-27y) — now the SHARED component. + * + * Moved to `src/components/QuickAdd.tsx` when /tasks grew the same + * group-context add: one capture box, two apps. This shim keeps every + * existing Projects import working unchanged. + */ + +export { QuickAdd } from "@/components/QuickAdd"; diff --git a/workbench/control_plane/src/app/projects/components/TableView.tsx b/workbench/control_plane/src/app/projects/components/TableView.tsx new file mode 100644 index 000000000..ae44a30f2 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/TableView.tsx @@ -0,0 +1,676 @@ +"use client"; + +/** + * Projects · the spreadsheet layout (WS-27x). + * + * The same task set as the board and the list, read from the same endpoint — + * one row per task, columns = the view's shown fields (plus Title), which is + * the same `shown_fields` set the chip gate reads, so the table and the cards + * can never disagree about what a view surfaces. + * + * Every cell edit drives the EXISTING write paths: `PATCH /tasks/{id}` for + * status, dates, priority and custom fields, `PUT …/assignees` for people — + * the same validation, the same `field_change` activity and the same revert + * as an edit typed into the panel. There is no table-only write path. + * + * Sorting is the server's (`lib/table.nextSort` maps a header click onto the + * gateway's `TASK_SORTS` keys — status is the WS-27w semantic sort); the page + * owns the fetch, so the sort state lives there and arrives as a prop. + * + * Sub-tasks nest indented under their parent when both are on the page + * (`lib/table.treeRows`); collapse state is local. The keyboard walks a CELL + * cursor (`lib/tableCursor`) — arrows move the ring, Enter edits (or opens + * the panel where a cell has no editor), Esc cancels back to cursor mode. + * Every group ends in the WS-27y quick-add, pre-filled with the group's value. + */ + +import Icon from "@/components/Icon"; +import { AvatarStack } from "@/components/TaskMeta"; +import { Input } from "@/components/ui/Input"; +import { durationLabel } from "@/lib/taskCard"; +import { useMemo, useRef, useState } from "react"; + +import type { FieldRow, StatusRow, TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; +import { parseAssignees } from "../lib/assignees"; +import { sortForView } from "../lib/board"; +import { taskRef } from "../lib/card"; +import { type FieldDef, displayValue, toInput, toWire } from "../lib/customFields"; +import { type GroupBy, type TaskGroup, UNSET, personLabel } from "../lib/grouping"; +import { dueInstantForDay, quickAddPrefill } from "../lib/quickAdd"; +import { + IMPORTANCE_OPTIONS, + type TableColumn, + type TableSort, + customKeyOf, + importanceLabel, + nextSort, + tableColumns, + treeRows, +} from "../lib/table"; +import { NO_CELL, clampCell, stepCell } from "../lib/tableCursor"; +import { QuickAdd } from "./QuickAdd"; +import { useFlash } from "./useFlash"; + +const SELECT = + "cc-control w-full rounded-lg border border-border bg-background px-2 py-1 " + + "text-xs text-foreground outline-none focus:border-primary/50"; + +/** The columns whose cells open an editor on Enter (or a click). */ +const EDITABLE = new Set(["status", "assignees", "due_at", "start_date", "importance"]); + +function isEditable(column: TableColumn): boolean { + if (EDITABLE.has(column.key)) return true; + // Custom fields: everything but multi_select, whose chips need more room + // than a cell — it stays read-only here, editable from the panel. + return Boolean(column.def && column.def.field_type !== "multi_select"); +} + +interface Props { + groups: TaskGroup[]; + groupBy: GroupBy; + statuses: StatusRow[]; + /** WS-27l — the root's custom field definitions, for custom columns. */ + fields: FieldRow[]; + /** WS-27x — the view's shown fields; columns are derived from it. */ + shownFields: readonly string[]; + /** Header sort — held by the page, which owns the fetch it drives. */ + sort: TableSort | null; + onSort: (next: TableSort | null) => void; + /** Where a quick-added task is created (the selected node). */ + projectId: string; + onCreated: (task: TaskRow) => void; + /** A cell edit landed — merge the fresh row into the page's task list. */ + onSaved: (task: TaskRow) => void; + onSelect: (task: TaskRow) => void; +} + +export function TableView({ + groups, + groupBy, + statuses, + fields, + shownFields, + sort, + onSort, + projectId, + onCreated, + onSaved, + onSelect, +}: Props) { + const [collapsed, setCollapsed] = useState>(new Set()); + const [cell, setCell] = useState(NO_CELL); + const [error, setError] = useState(null); + const gridRef = useRef(null); + const { flash, attach, scrollTo } = useFlash(); + + const statusById = useMemo( + () => new Map(statuses.map((s) => [s.id, s])), + [statuses] + ); + + const columns = useMemo( + () => tableColumns(shownFields, fields as FieldDef[]), + [shownFields, fields] + ); + // Title is always drawn — a table of chips with no names is not a table. + const colCount = 1 + columns.length; + + // Sections in render order. With a header sort active the server already + // ordered the page and that order is kept; otherwise each group falls back + // to the view's own hand-arranged order, exactly as the list does. + const sections = useMemo( + () => + groups + .filter((group) => group.tasks.length > 0) + .map((group) => ({ + key: group.key, + label: group.label, + rows: treeRows(sort ? group.tasks : sortForView(group.tasks), collapsed), + })), + [groups, sort, collapsed] + ); + + // The cursor's world: every rendered row, in render order. + const flatRows = useMemo( + () => sections.flatMap((section) => section.rows), + [sections] + ); + + // Clamped at READ time rather than synced by an effect — the rows and + // columns both shrink under the cursor (reloads, a field hidden mid-flight). + const at = clampCell(flatRows.length, colCount, cell); + + const total = groups.reduce((sum, group) => sum + group.tasks.length, 0); + + function toggleCollapse(taskId: string) { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + } + + function closeEditor() { + setCell((current) => ({ ...current, editing: false })); + gridRef.current?.focus(); + } + + async function saveCell(task: TaskRow, patch: Record) { + setError(null); + try { + const fresh = await projectsApi.patchTask(task.id, patch); + onSaved(fresh); + flash(task.id); + } catch (err) { + setError(String((err as Error).message)); + } + closeEditor(); + } + + async function saveAssignees(task: TaskRow, raw: string) { + setError(null); + try { + const result = await projectsApi.setAssignees(task.id, parseAssignees(raw)); + onSaved({ ...task, assignees: result.assignees }); + flash(task.id); + } catch (err) { + setError(String((err as Error).message)); + } + closeEditor(); + } + + async function quickAdd(title: string, groupKey: string) { + const plan = quickAddPrefill(groupBy, groupKey); + const created = await projectsApi.createTask({ + project_id: projectId, + title, + ...plan.create, + }); + if (plan.assignees?.length) { + // Best-effort, as on the list: the task exists; a failed PUT leaves it + // honestly unassigned rather than inviting a duplicate-creating retry. + try { + await projectsApi.setAssignees(created.id, plan.assignees); + } catch { + /* the table will show where it actually landed */ + } + } + flash(created.id); + onCreated(created); + } + + function onKeyDown(event: React.KeyboardEvent) { + // Keystrokes inside an editor (or the quick-add) belong to it; the cell + // editors surrender only Esc, via their own handlers. + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]" + ) + ) + return; + if (event.key === "Enter" && at.row >= 0 && !at.editing) { + const column = at.col === 0 ? null : columns[at.col - 1]; + if (!column || !isEditable(column)) { + // A cell with no editor: Enter opens the task, like the list's row + // cursor would. + event.preventDefault(); + onSelect(flatRows[at.row].task); + return; + } + } + const next = stepCell(flatRows.length, colCount, at, event.key); + if (!next) return; + event.preventDefault(); + setCell(next); + if (next.row >= 0 && next.row !== at.row) scrollTo(flatRows[next.row].task.id); + } + + function header(column: TableColumn) { + if (!column.sortKey) { + return {column.label}; + } + const active = sort?.key === column.sortKey; + return ( + + ); + } + + function readOnlyCell(task: TaskRow, column: TableColumn): React.ReactNode { + switch (column.key) { + case "status": + return statusById.get(task.status_id)?.name ?? "—"; + case "assignees": + return task.assignees?.length ? ( + + ) : ( + "—" + ); + case "due_at": + return task.due_at ? new Date(task.due_at).toLocaleDateString() : "—"; + case "start_date": + // A floating DATE — shown as stored, never routed through `new + // Date()`, which would move it a day west of Greenwich (see api.ts). + return task.start_date ? task.start_date.slice(0, 10) : "—"; + case "importance": + return importanceLabel(task.importance) || "—"; + case "subtasks": { + const counts = task.subtasks; + return counts && counts.total > 0 ? `${counts.done}/${counts.total}` : "—"; + } + case "blocked": + return task.blocked_by_count ? String(task.blocked_by_count) : "—"; + case "tags": + return task.tags?.length ? task.tags.join(", ") : "—"; + case "attachments": + // Counted only on the single-task read (WS-27i); the list rows this + // table draws honestly do not know. + return "—"; + case "estimate": + return durationLabel(task.estimate_mins) || "—"; + case "created_at": + return task.created_at ? new Date(task.created_at).toLocaleDateString() : "—"; + default: { + const key = customKeyOf(column.key); + if (!key || !column.def) return "—"; + return displayValue(column.def, (task.custom_fields ?? {})[key]); + } + } + } + + function editorCell(task: TaskRow, column: TableColumn): React.ReactNode { + if (column.key === "status") { + return ( + + ); + } + if (column.key === "importance") { + return ( + + ); + } + if (column.key === "assignees") { + return ( + void saveAssignees(task, value)} + onCancel={closeEditor} + /> + ); + } + if (column.key === "due_at") { + return ( + + // Noon local, the calendar quick-add's rule (`dueInstantForDay`), + // so the chosen day survives every viewer's timezone. + void saveCell(task, { due_at: value ? dueInstantForDay(value) : null }) + } + onCancel={closeEditor} + /> + ); + } + if (column.key === "start_date") { + return ( + void saveCell(task, { start_date: value || null })} + onCancel={closeEditor} + /> + ); + } + const key = customKeyOf(column.key); + const def = column.def; + if (!key || !def) return readOnlyCell(task, column); + const stored = (task.custom_fields ?? {})[key]; + if (def.field_type === "boolean") { + return ( + + void saveCell(task, { custom_fields: { [key]: e.target.checked } }) + } + onBlur={closeEditor} + onKeyDown={(e) => { + if (e.key === "Escape") closeEditor(); + }} + /> + ); + } + if (def.field_type === "select") { + return ( + + ); + } + return ( + + void saveCell(task, { + custom_fields: { [key]: toWire(def.field_type, value) }, + }) + } + onCancel={closeEditor} + /> + ); + } + + if (total === 0) { + return ( +
+

No tasks here yet.

+ {/* No group to land in, so the UNSET sentinel: every axis maps it to + the empty plan, and the server assigns the default status. */} + quickAdd(title, UNSET)} + className="max-w-md" + /> +
+ ); + } + + return ( +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + + {columns.map((column) => ( + + ))} + + + {sections.map((section, sectionIndex) => { + // This section's first row's index in the cursor's flat world. + const base = sections + .slice(0, sectionIndex) + .reduce((sum, s) => sum + s.rows.length, 0); + return ( + + {groupBy === "none" ? null : ( + + + + )} + {section.rows.map((row, rowIndex) => { + const task = row.task; + const flatIndex = base + rowIndex; + const cursorHere = at.row === flatIndex; + return ( + + + {columns.map((column, columnIndex) => { + const col = columnIndex + 1; + const here = cursorHere && at.col === col; + const editing = here && at.editing && isEditable(column); + return ( + + ); + })} + + ); + })} + {/* WS-27y machinery, per the ticket: a task added here lands in + THIS group, pre-filled by `quickAddPrefill`. */} + + + + + ); + })} +
+ {header({ key: "title", label: "Title", sortKey: "title" })} + + {header(column)} +
+ {section.label} + + {section.rows.length} + +
{ + setCell({ row: flatIndex, col: 0, editing: false }); + onSelect(task); + }} + className={`cursor-pointer px-3 py-2 text-foreground ${ + cursorHere && at.col === 0 ? "ring-2 ring-inset ring-ring" : "" + }`} + > + + {row.childCount > 0 ? ( + + ) : row.depth > 0 ? ( + + ) : null} + + {taskRef(task) ?? ""} + + + {task.title} + + + { + if (editing) return; + setCell({ + row: flatIndex, + col, + editing: isEditable(column), + }); + }} + className={`px-3 py-2 text-muted-foreground ${ + isEditable(column) ? "cursor-pointer" : "" + } ${here ? "ring-2 ring-inset ring-ring" : ""}`} + > + {editing ? editorCell(task, column) : readOnlyCell(task, column)} +
+ quickAdd(title, section.key)} + className="max-w-md" + /> +
+
+ ); +} + +/** + * A one-line text-ish cell editor: Enter commits, Esc cancels, blur commits + * (leaving a half-typed edit behind silently is worse than saving it). + * Uncontrolled — the draft dies with the editor, which is what Esc means. + */ +function CellText({ + label, + defaultValue, + type = "text", + placeholder, + onCommit, + onCancel, +}: { + label: string; + defaultValue: string; + type?: string; + placeholder?: string; + onCommit: (value: string) => void; + onCancel: () => void; +}) { + const cancelled = useRef(false); + return ( + { + if (e.key === "Enter") { + e.preventDefault(); + onCommit((e.target as HTMLInputElement).value); + } else if (e.key === "Escape") { + cancelled.current = true; + onCancel(); + } + }} + onBlur={(e) => { + if (cancelled.current) return; + // Only a real change commits — tabbing through an untouched cell + // must not write a PATCH and post a timeline entry for nothing. + if (e.target.value !== defaultValue) onCommit(e.target.value); + else onCancel(); + }} + /> + ); +} diff --git a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx index d35ca7acd..74a3454ef 100644 --- a/workbench/control_plane/src/app/projects/components/TaskBoard.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskBoard.tsx @@ -7,55 +7,336 @@ * default, but also assignee, project or priority (WS-27k). The grouping * decision is the page's; this component only draws it. * - * **Dragging is offered only when the columns are statuses.** A drop is a write - * to the field the columns represent, and status is the one that is a plain - * `PATCH status_id`: assignees are a separate PUT, priority is an integer, and - * moving a task between projects crosses a grant boundary. Letting a card be - * dragged into a column that cannot accept it — and snap back — is worse than - * the column being honestly static. + * WS-27y adds the second axis: with a sub-grouping chosen, the board becomes + * group columns × sub-group swimlanes, computed by `lib/swimlanes.ts`. Lanes + * collapse (state travels with the saved view), empty lanes hide unless + * asked, and a drop into a lane cell writes BOTH axes at once through + * `buildCellDropPatch`. + * + * **Dragging is always offered; refusing is explained.** Where the old board + * silently disabled dragging off the status axis, a drag over a target that + * cannot take the drop now overlays the target with `dropRefusal`'s reason — + * a card that snaps back wordlessly teaches people the board is broken, not + * that assignees are many-valued. * * Ordering is per view (D-PM-5): a drop writes fractional positions through * `planDrop`, which is one row in the normal case and the whole group on the * first drag into an unordered column. */ import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; +import Icon from "@/components/Icon"; +import Button from "@/components/ui/Button"; import { useMemo, useState } from "react"; -import type { TaskRow } from "../lib/api"; -import { buildColumnDropUpdate, planDrop, sortForView } from "../lib/board"; -import { cardChips } from "../lib/card"; -import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; +import type { StatusRow, TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; +import { + buildCellDropPatch, + dropRefusal, + planDrop, + sortForView, +} from "../lib/board"; +import { taskRef, visibleChips } from "../lib/card"; +import { clampCursor, stepCursor } from "../lib/cursor"; +import { + type BoardLanes, + type GroupBy, + type TaskGroup, + personLabel, +} from "../lib/grouping"; +import { mergePlans, quickAddPrefill } from "../lib/quickAdd"; +import { + type Swimlane, + buildSwimlanes, + hiddenLaneCount, + visibleLanes, +} from "../lib/swimlanes"; +import { QuickAdd } from "./QuickAdd"; +import { useFlash } from "./useFlash"; + +const NOBODY: ReadonlySet = new Set(); interface Props { groups: TaskGroup[]; groupBy: GroupBy; + /** WS-27y — the second axis and its lane state. */ + lanes: BoardLanes; + onToggleLane: (key: string) => void; + onShowEmptyLanes: (show: boolean) => void; + /** Context the lane computation needs — same as the page's `groupTasks` call. */ + statuses: StatusRow[]; + projectName?: (id: string) => string; + /** Where a quick-added task is created (the selected node). */ + projectId: string; + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; + onCreated: (task: TaskRow) => void; /** WS-27n — ids currently multi-selected. Empty when nobody is bulk editing. */ selected?: ReadonlySet; onToggle?: (id: string, shift: boolean) => void; + /** WS-27y — Shift+Arrow grew the selection to exactly these ids. */ + onExtendSelection?: (ids: string[]) => void; onSelect: (task: TaskRow) => void; onDrop: ( task: TaskRow, writes: ReturnType, - patch: Record | null + patch: Record | null ) => void; } export function TaskBoard({ groups, groupBy, + lanes, + onToggleLane, + onShowEmptyLanes, + statuses, + projectName, + projectId, + shownFields, + onCreated, selected, onToggle, + onExtendSelection, onSelect, onDrop, }: Props) { const [dragging, setDragging] = useState(null); - const draggable = groupBy === "status"; + const [over, setOver] = useState<{ col: string; lane: string | null } | null>( + null + ); + const [cursor, setCursor] = useState(-1); + const [anchor, setAnchor] = useState(null); + const { flash, attach, scrollTo } = useFlash(); + + // `fromConfig` normalises an equal sub-axis away, but the axis pickers can + // briefly say "status × status" between two keystrokes — treat it as flat. + const subBy = lanes.subGroupBy === groupBy ? "none" : lanes.subGroupBy; + const laned = subBy !== "none"; const columns = useMemo( () => groups.map((group) => ({ ...group, tasks: sortForView(group.tasks) })), [groups] ); + const swimlanes = useMemo( + () => + laned ? buildSwimlanes(columns, subBy, { statuses, projectName }) : null, + [laned, columns, subBy, statuses, projectName] + ); + const shownLanes = useMemo( + () => (swimlanes ? visibleLanes(swimlanes, lanes.showEmptyLanes) : null), + [swimlanes, lanes.showEmptyLanes] + ); + const collapsed = useMemo( + () => new Set(lanes.collapsedLanes), + [lanes.collapsedLanes] + ); + + // The keyboard cursor's world: every card in render order, each id once. + const rows = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + const push = (task: TaskRow) => { + if (!seen.has(task.id)) { + seen.add(task.id); + out.push(task.id); + } + }; + if (shownLanes) { + for (const lane of shownLanes) { + if (collapsed.has(lane.key)) continue; + for (const cell of lane.cells) for (const task of cell) push(task); + } + } else { + for (const column of columns) for (const task of column.tasks) push(task); + } + return out; + }, [shownLanes, collapsed, columns]); + + const taskById = useMemo(() => { + const map = new Map(); + for (const column of columns) + for (const task of column.tasks) map.set(task.id, task); + return map; + }, [columns]); + + // Clamped at READ time rather than synced by an effect: the rows shrink + // under the cursor on every reload, and a state write per reload is exactly + // the cascading-render pattern the lint forbids. + const cursorAt = clampCursor(rows.length, cursor); + + function onKeyDown(event: React.KeyboardEvent) { + // Keystrokes inside a quick-add (or any control) are that control's. + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]" + ) + ) + return; + const picked = selected ?? NOBODY; + const next = stepCursor( + rows, + { cursor: cursorAt, anchor, selection: picked }, + event.key, + event.shiftKey + ); + if (!next) return; + event.preventDefault(); + setCursor(next.cursor); + setAnchor(next.anchor); + if (next.selection !== picked) onExtendSelection?.([...next.selection]); + if (next.open) { + const task = taskById.get(next.open); + if (task) onSelect(task); + } + if (next.cursor >= 0) scrollTo(rows[next.cursor]); + } + + /** The refusal for the target under the drag, or null. One rule for column + * and cell targets: a cell involves the lane axis, a plain column does not. */ + const refusalFor = (laneKey: string | null): string | null => + dropRefusal(groupBy, laneKey === null ? null : subBy, dragging ?? undefined); + + function dropInto( + event: React.DragEvent, + colKey: string, + cellTasks: TaskRow[], + laneKey: string | null + ) { + event.preventDefault(); + setOver(null); + const task = dragging; + setDragging(null); + if (!task || refusalFor(laneKey)) return; + const writes = planDrop(cellTasks, task.id, cellTasks.length, colKey); + const patch = buildCellDropPatch( + task, + groupBy, + colKey, + laneKey === null ? null : subBy, + laneKey + ); + flash(task.id); + onDrop(task, writes, patch); + } + + const targetProps = ( + colKey: string, + cellTasks: TaskRow[], + laneKey: string | null + ) => ({ + onDragOver: (event: React.DragEvent) => { + if (!dragging) return; + // preventDefault even when refusing — the browser must keep sending + // events or the overlay could never show; the refusal is enforced in + // `dropInto`, and the cursor says "no" via dropEffect. + event.preventDefault(); + event.dataTransfer.dropEffect = refusalFor(laneKey) ? "none" : "move"; + setOver((current) => + current?.col === colKey && current?.lane === laneKey + ? current + : { col: colKey, lane: laneKey } + ); + }, + onDragLeave: (event: React.DragEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node)) return; + setOver((current) => + current?.col === colKey && current?.lane === laneKey ? null : current + ); + }, + onDrop: (event: React.DragEvent) => + dropInto(event, colKey, cellTasks, laneKey), + }); + + async function quickAdd(title: string, colKey: string, laneKey: string | null) { + const plan = + laneKey === null + ? quickAddPrefill(groupBy, colKey) + : mergePlans(quickAddPrefill(groupBy, colKey), quickAddPrefill(subBy, laneKey)); + const created = await projectsApi.createTask({ + project_id: projectId, + title, + ...plan.create, + }); + if (plan.assignees?.length) { + // Best-effort: the task already exists. A failed PUT leaves it visible + // in Unassigned, which is honest; throwing here would invite a retry + // that creates it twice. + try { + await projectsApi.setAssignees(created.id, plan.assignees); + } catch { + /* the board will show where it actually landed */ + } + } + flash(created.id); + onCreated(created); + } + + const refusalOverlay = (laneKey: string | null, colKey: string) => + over?.col === colKey && over?.lane === laneKey && dragging ? ( + (() => { + const reason = refusalFor(laneKey); + return reason ? ( +
+ {reason} +
+ ) : null; + })() + ) : null; + + const card = (task: TaskRow) => ( +
  • + {onToggle ? ( + e.stopPropagation()} + onChange={(e) => + onToggle(task.id, (e.nativeEvent as MouseEvent).shiftKey) + } + /> + ) : null} + +
  • + ); + if (columns.length === 0) { return (

    @@ -64,95 +345,147 @@ export function TaskBoard({ ); } + const droppable = dropRefusal(groupBy, laned ? subBy : null) === null; + const hidden = swimlanes ? hiddenLaneCount(swimlanes) : 0; + return ( -

    - {columns.map((column) => ( -
    { - if (draggable) e.preventDefault(); - }} - onDrop={(e) => { - if (!draggable) return; - e.preventDefault(); - if (!dragging) return; - const writes = planDrop( - column.tasks, - dragging.id, - column.tasks.length, - column.key - ); - const patch = - dragging.status_id === column.key - ? null - : buildColumnDropUpdate(groupBy, column.key); - onDrop(dragging, writes, patch); - setDragging(null); - }} - className="flex w-72 shrink-0 flex-col rounded-lg border border-border bg-card" - > -
    - - {column.label} - +
    + {laned ? ( +
    + + {!lanes.showEmptyLanes && hidden > 0 ? ( - {column.tasks.length} + {hidden} empty {hidden === 1 ? "lane" : "lanes"} hidden -
    -
      - {column.tasks.map((task) => ( -
    • - {onToggle ? ( - e.stopPropagation()} - onChange={(e) => - onToggle(task.id, (e.nativeEvent as MouseEvent).shiftKey) - } - /> + ) : null} +
    + ) : null} + + {!laned ? ( +
    + {columns.map((column) => ( +
    + {refusalOverlay(null, column.key)} +
    + + {column.label} + + + {column.tasks.length} + +
    +
      + {column.tasks.map((task) => card(task))} + {column.tasks.length === 0 ? ( +
    • + {droppable ? "Drop here" : "Nothing here"} +
    • ) : null} -
    +
    + quickAdd(title, column.key, null)} + /> +
    +
    + ))} +
    + ) : ( +
    +
    + {/* Column headers once, up top — every lane below shares them. */} +
    + {columns.map((column) => ( +
    - - {task.title} + + {column.label} - {/* The chip row and the owner strip are the shared card - vocabulary (WS-27s) — the same components /tasks draws, - so a task looks like the same kind of thing in both. */} - - - {task.task_number ? `#${task.task_number}` : ""} - + + {column.tasks.length} - - - ))} - {column.tasks.length === 0 ? ( -
  • - {draggable ? "Drop here" : "Nothing here"} -
  • +
    + ))} +
    + + {(shownLanes ?? []).map((lane) => { + const folded = collapsed.has(lane.key); + return ( +
    + + {folded ? null : ( +
    + {lane.cells.map((cell, columnIndex) => { + const column = columns[columnIndex]; + return ( +
    + {refusalOverlay(lane.key, column.key)} +
      + {cell.map((task) => card(task))} + {cell.length === 0 ? ( +
    • + {droppable ? "Drop here" : "—"} +
    • + ) : null} +
    +
    + + quickAdd(title, column.key, lane.key) + } + /> +
    +
    + ); + })} +
    + )} +
    + ); + })} + {shownLanes && shownLanes.length === 0 ? ( +

    + Every lane is empty. Turn on “Show empty lanes” to see them. +

    ) : null} - - - ))} +
    +
    + )}
    ); } diff --git a/workbench/control_plane/src/app/projects/components/TaskList.tsx b/workbench/control_plane/src/app/projects/components/TaskList.tsx index d479e5fce..fc2e7ee66 100644 --- a/workbench/control_plane/src/app/projects/components/TaskList.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskList.tsx @@ -12,23 +12,43 @@ * the board would have drawn as columns, as headed sections. Switching between * board and list must not change which tasks are on screen or how they are * gathered, which is why both take the output of one `groupTasks` call. + * + * WS-27y: every group section ends in a quick-add pre-filled with the group's + * value (`lib/quickAdd.ts` owns that mapping), and an arrow-key cursor walks + * the rows — Shift extends the WS-27n selection, Enter opens the panel. */ +import Icon from "@/components/Icon"; import { AvatarStack, TaskMeta } from "@/components/TaskMeta"; +import { useMemo, useState } from "react"; import type { StatusRow, TaskRow } from "../lib/api"; +import { projectsApi } from "../lib/api"; import { sortForView } from "../lib/board"; -import { cardChips } from "../lib/card"; +import { taskRef, visibleChips } from "../lib/card"; +import { clampCursor, stepCursor } from "../lib/cursor"; import { type GroupBy, type TaskGroup, personLabel } from "../lib/grouping"; +import { quickAddPrefill } from "../lib/quickAdd"; +import { QuickAdd } from "./QuickAdd"; +import { useFlash } from "./useFlash"; + +const NOBODY: ReadonlySet = new Set(); interface Props { groups: TaskGroup[]; groupBy: GroupBy; statuses: StatusRow[]; + /** WS-27y — where a quick-added task is created (the selected node). */ + projectId: string; + /** WS-27x — the view's shown fields; chips a hidden field earned are not drawn. */ + shownFields: readonly string[]; + onCreated: (task: TaskRow) => void; /** WS-27n — ids currently multi-selected. */ selected?: ReadonlySet; onToggle?: (id: string, shift: boolean) => void; onToggleAll?: () => void; allChecked?: boolean; + /** WS-27y — Shift+Arrow grew the selection to exactly these ids. */ + onExtendSelection?: (ids: string[]) => void; onSelect: (task: TaskRow) => void; } @@ -36,21 +56,127 @@ export function TaskList({ groups, groupBy, statuses, + projectId, + shownFields, + onCreated, selected, onToggle, onToggleAll, allChecked = false, + onExtendSelection, onSelect, }: Props) { + const [cursor, setCursor] = useState(-1); + const [anchor, setAnchor] = useState(null); + const { flash, attach, scrollTo } = useFlash(); + // Group sections collapse, /tasks-style (TaskListGrouped's grammar): a + // chevron on the header, local state, the header row itself stays put. + const [folded, setFolded] = useState>(new Set()); + const toggleFold = (key: string) => + setFolded((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + const statusById = new Map(statuses.map((s) => [s.id, s])); const total = groups.reduce((sum, group) => sum + group.tasks.length, 0); + // The cursor's world: rendered order, each id once (a two-owner task is + // drawn in two sections but is one row to the keyboard, as to WS-27n). + const sections = useMemo( + () => + groups + .filter((group) => group.tasks.length > 0) + .map((group) => ({ ...group, tasks: sortForView(group.tasks) })), + [groups] + ); + const rows = useMemo(() => { + const seen = new Set(); + const out: string[] = []; + for (const group of sections) { + // A folded section's rows are off-screen, so the cursor skips them — + // same rule the board applies to a collapsed lane. + if (folded.has(group.key)) continue; + for (const task of group.tasks) + if (!seen.has(task.id)) { + seen.add(task.id); + out.push(task.id); + } + } + return out; + }, [sections, folded]); + const taskById = useMemo(() => { + const map = new Map(); + for (const group of sections) for (const task of group.tasks) map.set(task.id, task); + return map; + }, [sections]); + + // Clamped at READ time rather than synced by an effect: the rows shrink + // under the cursor on every reload, and a state write per reload is exactly + // the cascading-render pattern the lint forbids. + const cursorAt = clampCursor(rows.length, cursor); + + function onKeyDown(event: React.KeyboardEvent) { + if ( + (event.target as HTMLElement).closest( + "input, textarea, select, [contenteditable=true]" + ) + ) + return; + const picked = selected ?? NOBODY; + const next = stepCursor( + rows, + { cursor: cursorAt, anchor, selection: picked }, + event.key, + event.shiftKey + ); + if (!next) return; + event.preventDefault(); + setCursor(next.cursor); + setAnchor(next.anchor); + if (next.selection !== picked) onExtendSelection?.([...next.selection]); + if (next.open) { + const task = taskById.get(next.open); + if (task) onSelect(task); + } + if (next.cursor >= 0) scrollTo(rows[next.cursor]); + } + + async function quickAdd(title: string, groupKey: string) { + const plan = quickAddPrefill(groupBy, groupKey); + const created = await projectsApi.createTask({ + project_id: projectId, + title, + ...plan.create, + }); + if (plan.assignees?.length) { + // Best-effort: the task exists; a failed PUT leaves it honestly in + // Unassigned rather than inviting a duplicate-creating retry. + try { + await projectsApi.setAssignees(created.id, plan.assignees); + } catch { + /* the list will show where it actually landed */ + } + } + flash(created.id); + onCreated(created); + } + if (total === 0) { return

    No tasks here yet.

    ; } + const columnCount = onToggle ? 6 : 5; + return ( -
    +
    @@ -76,79 +202,113 @@ export function TaskList({ - {groups.map((group) => { - // An empty status lane is kept on the board so a missing column reads - // as a missing state; a list has no columns, so an empty section is - // just a heading with nothing under it. Drop it. - if (group.tasks.length === 0) return null; + {/* An empty status lane is kept on the board so a missing column reads + as a missing state; a list has no columns, so an empty section is + just a heading with nothing under it — `sections` dropped it. */} + {sections.map((group) => { + const isFolded = groupBy !== "none" && folded.has(group.key); return ( - - {groupBy === "none" ? null : ( - - + - - )} - {sortForView(group.tasks).map((task) => { - const status = statusById.get(task.status_id); - return ( - onSelect(task)} - className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted ${ - selected?.has(task.id) ? "bg-accent/40" : "" - }`} - > - {onToggle ? ( - - ) : null} - - - + )} + {isFolded ? null : group.tasks.map((task) => { + const status = statusById.get(task.status_id); + const atCursor = cursorAt >= 0 && rows[cursorAt] === task.id; + return ( + onSelect(task)} + className={`cursor-pointer border-b border-border last:border-0 hover:bg-muted ${ + selected?.has(task.id) ? "bg-accent/40" : "" + } ${atCursor ? "bg-muted/60 ring-2 ring-inset ring-ring" : ""}`} + > + {onToggle ? ( + - - - - ); - })} - + ) : null} + + + + + + + ); + })} + {/* WS-27y — the group's own capture box: a task added here lands + in THIS group, pre-filled by `quickAddPrefill`. Folded away + with the rows, exactly as /tasks folds its sections. */} + {isFolded ? null : ( + + + + )} + ); })}
    Details
    + {groupBy === "none" ? null : ( +
    + {/* The /tasks group-header grammar (TaskListGrouped): + chevron to collapse, label, then the count as a pill — + so the two apps' grouped lists read identically. */} +
    - e.stopPropagation()} - onChange={(e) => - onToggle( - task.id, - (e.nativeEvent as MouseEvent).shiftKey, - ) - } - /> - - {task.task_number ?? "—"} - - - {task.title} - - - {status?.name ?? "—"} + + +
    + e.stopPropagation()} + onChange={(e) => + onToggle( + task.id, + (e.nativeEvent as MouseEvent).shiftKey, + ) + } + /> - {task.assignees?.length ? ( - - ) : ( - "—" - )} - - -
    + {taskRef(task) ?? "—"} + + + {task.title} + + + {status?.name ?? "—"} + + {task.assignees?.length ? ( + + ) : ( + "—" + )} + + +
    + quickAdd(title, group.key)} + className="max-w-md" + /> +
    diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index b3e92e872..74ef1091e 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -8,6 +8,7 @@ * a comment in the same stream, which is the point of the shared spine. */ import Icon from "@/components/Icon"; +import Button from "@/components/ui/Button"; import { useEffect, useRef, useState } from "react"; import { @@ -19,7 +20,10 @@ import { type TaskRow, attachmentsApi, projectsApi, + watchersApi, } from "../lib/api"; +import { taskDeepLink, taskRef } from "../lib/card"; +import { isAutomated } from "../lib/lifecycle"; import { CustomFieldValues } from "./CustomFieldValues"; import { TagPicker } from "./TagPicker"; import { RepeatEditor } from "./RepeatEditor"; @@ -117,7 +121,12 @@ export function TaskPanel({ const [relationsKey, setRelationsKey] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // WS-27w item 6 — the copy-deep-link affordance's "it worked" flash. + const [copied, setCopied] = useState(false); const [files, setFiles] = useState([]); + // WS-27v — null until the read lands, so the toggle never renders a state + // it is only guessing at. + const [watching, setWatching] = useState(null); const assignees = task.assignees ?? []; // Agents are excluded: an agent cannot receive a notification (migration // 152's CHECK), so offering to mention one would promise nothing. @@ -139,11 +148,31 @@ export function TaskPanel({ // Attachments failing must not blank the panel: the timeline and the // status control are the reason somebody opened it. .catch(() => undefined); + setWatching(null); + watchersApi + .get(task.id) + .then((res) => { + if (live) setWatching(res.watching); + }) + // Same posture as attachments: no toggle beats a blank panel. + .catch(() => undefined); return () => { live = false; }; }, [task.id]); + async function toggleWatch() { + if (watching === null) return; + // Optimistic — both writes are idempotent, so a failure just reverts. + const next = !watching; + setWatching(next); + try { + await (next ? watchersApi.watch(task.id) : watchersApi.unwatch(task.id)); + } catch { + setWatching(!next); + } + } + async function uploadFiles(picked: FileList | null) { if (!picked || picked.length === 0) return; setBusy(true); @@ -277,6 +306,26 @@ export function TaskPanel({ } } + /** + * Copy a URL that reopens this panel — `/projects?task=`, the deep-link + * shape the page already reads (WS-28b). The icon flips to a check briefly, + * because a copy with no acknowledgement gets clicked three times. + */ + async function copyDeepLink() { + try { + await navigator.clipboard.writeText( + taskDeepLink(task, window.location.origin), + ); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can be unavailable (permissions, insecure context). The + // link is not lost — opening the task by deep link shows it in the + // address bar — so this fails quietly rather than turning a copy + // button into an error banner. + } + } + /** Insert `@address` at the caret, so nobody has to type the token by hand. */ function mention(who: string) { const box = commentBox.current; @@ -295,19 +344,46 @@ export function TaskPanel({