diff --git a/ai-company-brain/AGENTS.md b/ai-company-brain/AGENTS.md index 69d28a8cb..dfee53c95 100644 --- a/ai-company-brain/AGENTS.md +++ b/ai-company-brain/AGENTS.md @@ -112,7 +112,8 @@ to it and to `competitive_hardening_2026-07.md` (CH-*) rather than re-describe t | [`email_app_master_plan.md`](specs/email_app_master_plan.md) | **Email master** — consolidated state + prioritized completion roadmap (absorbed the inventory, parity plan, tool plan; evidence in `specs/archive/email_feature_review_2026-07.md` — archived 2026-08-01) | 🔄 Phase 1 "stop the lying" open; #113 sweep armistice done | | [`task_manager_app.md`](specs/task_manager_app.md) | **Task Manager (GTD)** — client + `task-manager` agent + provider layer | 🔄 capture/clarify/organize + provider **sync-pull** + Engage "Now" live; **Waiting-For live 2026-08-02** (grouped view + overdue/stale flags; `gtd_waiting.expected_by` = an explicit promise only — NULL ⇒ the overdue line reads the item's live `due_at`, never a derived copy); open: follow-up nudges (owner-gated), Action-Broker-gated push, Weekly Review, Horizons | | [`task_manager_harness_2026-07.md`](specs/task_manager_harness_2026-07.md) | Task-manager × harness engineering (app-layer sibling) | 🔄 Tier 1 shipped (2026-07-03); Tier 2 planned | -| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native org-level project management in the People Center, sliced into every Center as (app + scope): departments→projects→subprojects→tasks→subtasks (`pm_*`, Paca-shape two-self-FK hierarchy), grant-based scoping on the shipped `email\|group:\|org` vocabulary, per-view fractional ordering, single activity spine; ClickUp two-way coexistence sync (three-way field merge, broker-gated push — **blocked on BO-1a/BO-1b**) then cutover + retirement; personal `/tasks` mirror via an internal provider; `pm.*` events feed `/workflows` and assign-to-`agent:` dispatches real runs. **Read §8 D-PM-8/9/10 (owner-answered 2026-08-06) before building:** no portfolio layer (grants are the only grouping axis) · agent edits to ClickUp-linked tasks are treated exactly like human edits, and D-PM-9's Cost paragraph names what that does and does not guarantee · Spaces map to Centers explicitly from agent-proposed suggestions, an agent may propose a mapping and must never apply one, and an unmapped Space still imports in full | 🟢 **a + b + d BUILT 2026-08-06** (schema + grant read model; ClickUp importer + mapping plan; `/projects` UI + Center projections) — **not deployed, and neither import endpoint has been run**; c gated on BO-1a/BO-1b, e–g open | +| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native org-level project management in the People Center, sliced into every Center as (app + scope): departments→projects→subprojects→tasks→subtasks (`pm_*`, Paca-shape two-self-FK hierarchy), grant-based scoping on the shipped `email\|group:\|org` vocabulary, per-view fractional ordering, single activity spine; ClickUp two-way coexistence sync (three-way field merge, broker-gated push — **blocked on BO-1a/BO-1b**) then cutover + retirement; personal `/tasks` mirror via an internal provider; `pm.*` events feed `/workflows` and assign-to-`agent:` dispatches real runs. **Read §8 D-PM-8/9/10 (owner-answered 2026-08-06) before building:** no portfolio layer (grants are the only grouping axis) · agent edits to ClickUp-linked tasks are treated exactly like human edits, and D-PM-9's Cost paragraph names what that does and does not guarantee · Spaces map to Centers explicitly from agent-proposed suggestions, an agent may propose a mapping and must never apply one, and an unmapped Space still imports in full | 🟢 **a + b + d + e BUILT 2026-08-06** (schema + grant read model; ClickUp importer + mapping plan; `/projects` UI + Center projections; the personal lens). ⚠️ **D-PM-6 was REVISED 2026-08-06** — one task store, not a mirror: read it before touching `/tasks`. **Not deployed, and neither import endpoint has been run**; c gated on BO-1a/BO-1b; f, g, h open | +| [`people_center_app.md`](specs/people_center_app.md) | **People Center (WS-28)** — the directory, person page, org chart, capability search and seats matrix, plus the four seams where People meets Projects. Owns **surfaces, not facts** — every fact is cited to its owning spec. Read §2 first: there are **two people stores on purpose** (`app_user` = can they sign in; `gtd_people` = who are they and what can they do), the directory must include people with no login, and migration 49's key shape (UNIQUE on `name`, nothing on `email`) makes the join ambiguous until WS-28a fixes it | 🔲 spec'd 2026-08-06, nothing built | | [`paca_pm_research_2026-08.md`](specs/paca_pm_research_2026-08.md) | **Paca PM-platform research** — `Paca-AI/paca` v0.11.0 deep dive (Apache-2.0; patterns, no code): hierarchy/statuses/ordering/views data model, trigger→condition→action automation graph, assignment→agent dispatch chain, MCP tool-design lessons, with a 14-row adopt/adapt/refuse table annealed into WS-27 | 🟢 research complete (reference-only, owns no work) | | [`llm_caching_memory.md`](specs/llm_caching_memory.md) | Prompt caching (ADR-008) + session memory | 🔄 caching **shipped & wired**; session-memory shipped but **inert by default** (→ BO-21); Phase 7 open | | [`mcp_plugin_integration.md`](specs/mcp_plugin_integration.md) | MCP servers vs Claude plugins vs REST | 🔄 MCP half **built** (`_inject_mcp_servers`); plugin store not started | diff --git a/ai-company-brain/specs/paca_pm_research_2026-08.md b/ai-company-brain/specs/paca_pm_research_2026-08.md index 444390572..5612125db 100644 --- a/ai-company-brain/specs/paca_pm_research_2026-08.md +++ b/ai-company-brain/specs/paca_pm_research_2026-08.md @@ -200,6 +200,14 @@ AND-join/idempotency discipline. WS-27 emits task events into the existing types; deeper engine uplifts (multi-branch switch, step snapshots, dependency map) are recorded as `workflows_app.md` backlog, not duplicated. +**Written up 2026-08-06 → [`workflows_app.md`](workflows_app.md) §13.** This section's +findings now have a home that owns work: eight items **U1–U8**, each pairing the Paca design +above with that engine's *measured* current state and a done-when. Read §13, not this +section, when implementing — §13 also records the five Paca features **deliberately refused** +(`call_api`'s reader-visible headers, a sibling worker process, the WASM plugin runtime, a +second engine, and a per-fire bookkeeping table where our CAS on `last_fired_at` is already +better), so the refusals do not read as oversights to a later implementer. + ## 5. Agent integration — the dispatch chain The chain is fully event-driven; the HTTP handler never calls the agent runtime: diff --git a/ai-company-brain/specs/people_center_app.md b/ai-company-brain/specs/people_center_app.md new file mode 100644 index 000000000..05d32b6fa --- /dev/null +++ b/ai-company-brain/specs/people_center_app.md @@ -0,0 +1,384 @@ +# People Center — the directory, the org chart, and the assignment seam + +> **Product:** CommandCenter · **Feature:** People Center (`/centers/people` and the +> `/people` app behind it) · **Created:** 2026-08-06 · **Status:** 🔲 **SPEC, nothing built** +> · **Owner:** vjvarada · **Board row: WS-28** +> +> **Scope, owner-set 2026-08-06:** directory, skills, org chart, capacity, and the +> seats/roles view that decides which Center somebody belongs to — *exactly what assignment +> and planning need*. Leave, onboarding and hiring are named as later phases in §8 and are +> deliberately not designed here. +> +> **This spec owns SURFACES, not facts.** Every fact about people, permissions and Centers +> is owned elsewhere and cited, never restated: +> - `specs/task_manager_hr_planning_and_memory.md` — HR intelligence, résumé ingestion, +> capability vectors. **Owns the data**; this doc owns how it is seen and edited. +> - `specs/org_access_control.md` — members, roles, per-user overrides. **Owns identity.** +> - `specs/colleague_onboarding.md` — the invite runbook and the role × app capability +> matrix. **Owns the process.** +> - `specs/department_centers.md` — Centers, groups, the five-place registration checklist. +> **Owns the projection model.** +> - `specs/project_management_app.md` — projects, tasks, assignment. **Owns the work.** +> +> A reader who wants "what is a manager allowed to see" goes to `colleague_onboarding.md` +> §3. This doc answers "where do I click to change who someone reports to". + +--- + +## 1. Why this exists + +Assignment is the whole reason. The Projects app (WS-27) can hand a task to +`alice@fracktal.in` today, but nothing in the product answers the questions a person asks +*before* they assign: + +- Who is there? Who is in Operations? +- Who knows about extruder firmware? +- Who has capacity this week, and who is already at 40 hours? +- Who does this person report to, and whose approval does this need? + +Those answers exist in the database already (`gtd_people` has skills, capacity, a manager +link and a 1536-dim capability vector) and are reachable through exactly one narrow surface +— a table inside the Tasks app. **The People Center is where they become a first-class +place**, and the Projects app reads them rather than growing its own copy. + +**Non-goals (v1):** payroll, performance reviews, compensation, time-off balances, +applicant tracking. §8 records where those would go if they are ever wanted. + +--- + +## 2. The two people stores, and why there are two + +This is the single most important thing to understand before building anything here, and it +is not a defect to be tidied away. + +| | `app_user` (+ `user_role`, `org_group_member`) | `gtd_people` (+ `gtd_person_resumes`) | +|---|---|---| +| Answers | *Can they sign in, and what may they see?* | *Who are they, what can they do, who do they report to?* | +| Created by | An invite (`POST /admin/members`) or a sign-in request | An import, a résumé upload, or a hand-added row | +| Owned by | `org_access_control.md` | `task_manager_hr_planning_and_memory.md` | +| Key | `email` | `name` (UNIQUE), with `email` nullable | +| Includes people who never sign in | No | **Yes** — contractors, a new hire before day one, a vendor contact | + +**They are joined on lowercased email, and the join is deliberately partial.** A person can +exist in the directory with no login (a contractor you assign work to but who has no seat), +and a login can exist with no directory row (a service identity). Collapsing them into one +table would force every contractor to become a member — which is a *licensing and access* +decision, not a directory one. + +⚠️ **`gtd_people.name` is UNIQUE and `email` is not.** That is migration 49's shape and it +is wrong for a directory that has to join on email: two people cannot share a name, and +nothing stops two rows carrying the same address. **P-1 in §7 fixes this** before the +directory becomes the assignment source, because an ambiguous email→person join would +silently attribute one person's capacity to another. + +**The rule this doc adds:** the People Center *renders both* and never creates a third +store. WS-13's board row already says it — "build the read view here, not a parallel store" +— and this is that instruction, made concrete. + +--- + +## 3. The surfaces + +Route: **`/people`**, gated on its own feature slug `people` (§6). The People Center's +landing page (`/centers/people`) links to it, and it is one app, not one per Center — the +same (app + scope) rule the Projects app follows. + +### 3.1 Directory — the default view + +A searchable list of everybody, one row per person, with a card/table toggle. + +**Row:** avatar (initials fallback), name, title, department + team, status pill +(`active` / `contractor` / `alumni`), and a compact skills strip (top 3 + "+4"). +**Search** matches name, title, department and skills in one box — the Projects app's +assignee picker uses the same endpoint, so a person findable in one is findable in both. +**Filters:** department, team, status, skill, "has capacity". + +⚠️ **The HR strip is permission-dependent and already enforced.** WS-24 N4 shipped +*directory open, HR fields restricted*: without `admin:members:read`, `skills`, +`resume_summary`, `years_experience` and the capacity trio are projected to null, and `?q=` +drops its skills clause so search cannot become an oracle. **The UI must render the +projected shape, never re-fetch a richer one** — and the empty state should say "restricted" +rather than "none", because a blank skills strip that means "you may not see this" and one +that means "nobody filled it in" are different facts. + +### 3.2 Person page + +One person, four panels: + +1. **Identity** — name, title, email, department, team, manager, status. The email carries a + badge saying whether this person has a **login** (`app_user` exists) or is + **directory-only**. That badge is the visible half of §2 and stops "why can't they see + the board" being a mystery. +2. **Skills** — editable chips, each showing its **source**: `stated` (typed by a human) or + `résumé` (extracted). `gtd_people.skills_source` already carries this. A skill nobody + stated and the parser inferred should not look like a claim the person made. +3. **Capacity** — `capacity_hours_per_week`, `current_load_hours_per_week`, and the derived + `available`. Rendered as one bar, not three numbers. **Load is computed from open + assigned tasks** (§5.2), so the bar moves when work is assigned — a hand-typed load + figure is stale the moment anyone assigns anything. +4. **Work** — this person's open tasks across every project the *viewer* may see, with a + link to each. Scoped by the viewer's grants, not the subject's: a Sales lead looking at + an Operations colleague sees the Sales work they share, not that person's whole life. + +**Writes** are gated on `admin:members:manage`, which WS-24 N4 already put on all four +people-write routes. A viewer without it sees the page read-only, with no disabled-button +theatre — the controls are absent. + +### 3.3 Org chart + +`gtd_people.manager_id` is a self-FK, so the chart is the same recursive render the project +tree already uses — and the same cycle guard applies (a manager loop is a hang, not a +diagram). + +- **Layout:** vertical tree, collapsible, with search-to-focus. +- **Unmanaged people** surface as roots. That is not an error state to hide: "nobody is + recorded as this person's manager" is exactly what an org chart should make obvious. +- **Drag to re-parent** writes `manager_id` (gated), with the cycle refused client-side + before the request so the tree does not optimistically render an impossible shape. +- **Center overlay:** each node can be tinted by the person's `org_group` membership, which + is what makes "who is actually in Operations" answerable — and shows the mismatches + between `gtd_people.department` (free text) and group membership (the real scoping). + **That mismatch is the point of the overlay**, not a rendering bug to smooth over. + +### 3.4 Seats & roles + +The bridge to `org_access_control.md`, rendered here because "who is in Sales" is a People +question that today requires visiting `/settings/groups`. + +A matrix: people down the side, the six Centers across the top, a checkbox at each +intersection reflecting `org_group_member`. Toggling one is a **group membership write** — +already an owner gate (`work_plan.md` §6 (d)), so this surface **proposes and does not +apply** for anyone but the owner: a non-owner's toggle produces a request in the existing +access-request queue rather than a silent 403. + +Beside it, each person's **role** (`owner`/`admin`/`manager`/`member`/`guest`) as a +read-only pill linking to `/settings/members`. Roles are not edited here — one editor for a +thing, and that editor already exists. + +### 3.5 Capability search — "who should do this?" + +A single box: *"Who can help with extruder firmware?"* Answers from three signals, most +defensible first, each labelled in the result: + +1. **Stated skills** — an exact/fuzzy match on `skills[]`. Deterministic. +2. **Résumé evidence** — a match in `gtd_person_resumes.extracted`, quoting the line. +3. **Capability vector** — cosine similarity on `capability_embedding` (1536-dim, already + populated by `POST /tasks/people/embed`), for the cases the first two miss. + +Each result shows **why it matched and how loaded that person is**, because a perfect skill +match at 45/40 hours is usually the wrong answer. This is a *suggester*: it never assigns. +The same rule as the ClickUp Space mapper (D-PM-10) and for the same reason — a system that +auto-assigns work to people is making a management decision it is not entitled to make. + +--- + +## 4. Where the People Center meets the Projects app + +Four seams. All are **reads from Projects into People**, or **reads from People into +Projects** — neither app writes the other's tables. + +### 4.1 The assignee picker (Projects → People) +Assigning a task opens a picker backed by the directory endpoint, not by a list of +`app_user` rows. It shows name, title, top skills and a capacity bar, and it lists +**agents** in the same picker under a separate heading — D-PM-4's one-vocabulary decision +made visible: handing work to an agent is the same gesture as handing it to a colleague. + +⚠️ The picker must offer **directory-only people** (no login). They can hold a task and +appear on a board; they simply cannot sign in to see it. Hiding them would make the +directory's whole point — contractors — unusable, and the assignee column is a plain string +precisely so this works (§3.6 of the Projects spec). + +### 4.2 Capacity (Projects → People) +`current_load_hours_per_week` is **derived**, not typed: the sum of `estimate_mins` over +open tasks assigned to that person, divided into a week. Recomputed on assignment change and +on a schedule. Until estimates are widely filled in, the bar shows *task count* with an +honest "no estimates" label rather than a load figure invented from nothing. + +### 4.3 The person's work panel (Projects → People) +`GET /projects/tasks?assignee=`, already shipped, scoped by the *viewer's* grants. + +### 4.4 Delegation (People → Projects) +From a person page: **"Assign work"** opens task creation with the assignee pre-filled. And +from the capability search: **"Assign to…"** on a result. Both land in the ordinary task +create flow — no second write path, so every rule the Projects app enforces (visibility, +status, activity) applies unchanged. + +--- + +## 5. Data model + +**No new people tables.** The People Center reads `gtd_people`, `gtd_person_resumes`, +`app_user`, `org_group` and `org_group_member`. Two additive changes only, both in §7: + +- **P-1** — fix `gtd_people`'s key shape (§2): drop the UNIQUE on `name`, add a partial + UNIQUE on `lower(email) WHERE email IS NOT NULL`. Without it the email join is ambiguous. +- **P-2** — `gtd_people.status` gains a CHECK (`active`/`contractor`/`alumni`/`invited`) and + `gtd_people.has_login` becomes a *derived* read, never a column: two columns that must + agree are two columns that can disagree. + +Capacity is computed (§4.2), not stored beyond the existing columns. + +--- + +## 6. Registration + +The four-place checklist the Projects app followed (`project_management_app.md` §5): + +1. `acb_auth.permissions.FEATURES` gains `"people"`. +2. A `feature_catalog` row at the next free migration number: `('people', 'People', + 'Directory, skills and org chart', '/people', 'apps', 57, false)`. +3. `nav.ts` `PANES` + `access.ts` `HREF_FEATURES` → `/people` → `people`. +4. The both-ways catalog↔FEATURES invariant picks it up; add the named + `test_people_is_registered_on_both_sides`. + +Plus the Center projection: `centers.ts` People Center's **"Directory & org chart"** sub-app +flips to `{status: "live", href: "/people"}` — closing WS-13's outstanding item, which asked +for exactly this read view. + +**Visibility posture:** `is_default false`, like `crm` and `projects`. The directory is +open to holders; the HR fields inside it are restricted by `admin:members:read` — a +restriction that already exists and must not be re-implemented here. + +--- + +## 7. Tickets + +**WS-28a — the key-shape fix (P-1, P-2).** ✅ **BUILT 2026-08-06** +(migration `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 static/hermetic +cases, 11 mutants red, 1 equivalent). +Done when: `gtd_people` no longer uniquely constrains `name`; a partial unique index exists +on `lower(email)`; a status CHECK exists; and a test proves two people may share a name and +may not share an address. + +**What P-1 did not name, and it matters:** `scripts/import_hr_people.py` upserts +`ON CONFLICT (name)`. Dropping `UNIQUE(name)` leaves that with no constraint to infer, so +the importer fails outright — "no unique or exclusion constraint matching the ON CONFLICT +specification". The fix is a **`source_key`** column (`:`) with its own +partial unique index. That key is honest about what it claims: the HR snapshot is a JSON +object keyed by name, so names are unique *within that file* whether or not they are unique +among humans. It also means a person hand-added in the People Center is never overwritten by +a snapshot re-import. Backfilled **before** the constraint is dropped, while `name` is still +guaranteed distinct — which is what makes the backfill collision-free by construction. + +**Nothing in this migration may block a deploy**, and that shaped both changes. +`apply_migrations.sh` replays every `02+` migration on every deploy under +`set -euo pipefail` + `ON_ERROR_STOP=1`; main has already been bitten twice this month by a +migration that stopped deploys. Both new constraints could plausibly fail on live rows: + +- **A duplicate address** would fail `CREATE UNIQUE INDEX`. The loser's address is moved to + a new `email_conflict` column instead — visible, reversible, non-blocking. Losing an + address silently would be worse than the ambiguity this fixes; aborting the deploy would + be worse than both. The winner is chosen **deterministically** (`updated_at`, then + `created_at`, then `id`) so a re-run against a restored backup cannot pick differently. +- **An unanticipated status value** would fail the CHECK. Migration 49 documented + `'active' | 'inactive' | …` and the `…` is the problem. Known legacy spellings are mapped + (`inactive|former|left` → `alumni`); anything else is **left alone rather than rewritten**, + and the constraint is added `NOT VALID` then validated in a guarded block. New writes are + enforced either way; a legacy offender leaves the constraint un-validated with a `NOTICE` + instead of stopping the deploy. + +⚠️ **`schema.generated.sql` is NOT refreshed** — `scripts/dump_schema.sh` needs a live +database with the ladder applied, which this build had no access to. Regenerate it on the +first deploy that applies 148, per `infra/postgres/README.md` step 3. + +**WS-28b — directory + person page.** ✅ **BUILT 2026-08-06** +(mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, +11 mutants red). +Done when: `/people` lists and filters; the person page renders all four panels; the HR +projection is honoured with a "restricted" empty state distinct from "none"; writes are +gated on `admin:members:manage` and absent (not disabled) without it. + +**The permission story here is a projection, not a refusal**, and that shaped everything. +Four decisions worth reading: + +- **The gate is new; the projection is imported.** `routes/people/core.py` re-exports + `tasks.core.can_read_hr_fields` rather than defining its own — two answers to "may this + caller see skills" are two answers waiting to drift, and a test asserts the *identity* of + the function object, not merely that both agree today. +- **Three filters are the same rule wearing different hats.** The `q` skills clause, the + `skill` filter and `has_capacity` are all dropped without `admin:members:read`, because + matching on a column that is then stripped turns the search box into an oracle for the + field the projection exists to hide. Dropping them silently would be its own defect, so + the response carries **`hr_visible`** and the UI states it once at the top instead of + leaving a blank strip to be misread as "nobody filled it in". +- **Load is computed, and says when it cannot be.** `current_load_hours_per_week` is a + number somebody typed once. The bar counts open assigned tasks — and carries + `unestimated`, because a task with no estimate adds no hours and a bar built from the sum + alone shows somebody holding thirty un-estimated tasks as completely free. When nothing + is estimated the bar refuses to draw a percentage rather than drawing a confident zero. +- **The work panel is scoped by the VIEWER**, via the Projects grant closure, and answers + `available: false` without `feature:projects` — "this surface is not yours" and "they + have nothing open" must not render identically. + +**Registration is five places, not four** — §6 lists `FEATURES`, `feature_catalog`, +`nav.ts`/`access.ts` and `centers.ts`, and there is a fifth that is hand-maintained and easy +to miss: `test_org_access_enforcement.GATED_ROUTERS`. A router absent from that registry is +not passing, it is *unchecked*. Also added: `test_projects_is_registered_on_both_sides`, +which WS-27a never wrote — the generic pair passes when **both** sides are missing a slug, +so only a named test catches a feature nobody registered. + +The person-page **writes stay on `/tasks/people`** under `admin:members:manage`. The +`/api/people` proxy is **GET-only** for that reason: forwarding write verbs to endpoints the +gateway does not serve would mint a second, hollow write path, and the first person to find +it would reasonably assume it worked. + +**WS-28b-write — the person write half.** 🟢 AGENT-SAFE. *(Minted 2026-08-06, and it is a +REGRESSION to close, not a new idea.)* +The tasks app's People view was removed the same day (owner-directed scope narrowing, +`task_manager_app.md` §6.0), and `PersonEditor` went with it. That was the only UI for +creating a person, editing their skills, and uploading a résumé. **The API is untouched** — +`POST /tasks/people`, `PATCH /tasks/people/{id}`, `POST /tasks/people/{id}/resume`, all on +`admin:members:manage` — and `taskStore.uploadPersonResume` still wraps it, so nothing was +deleted. But until this lands, an admin cannot do any of it from the product. +Done when: the person page grows edit + résumé upload for a holder of +`admin:members:manage`; the controls are **absent** rather than disabled without it (§3.2); +and the résumé upload reports what it merged, as `PersonEditor` did. + +**WS-28c — org chart.** 🟢 AGENT-SAFE. +Done when: the tree renders from `manager_id`, unmanaged people surface as roots, a +re-parent that would create a cycle is refused before the request, and the Center overlay +shows department/group mismatches rather than hiding them. + +**WS-28d — capability search.** 🟢 AGENT-SAFE to build; the ranking prompt is **EVAL-LOCKED**. +Done when: all three signals are queried, each result names which matched and shows load, +and the surface never writes an assignment. + +**WS-28e — the Projects seams.** 🟢 AGENT-SAFE. +Done when: the assignee picker is directory-backed and lists agents and directory-only +people; capacity is derived from open assigned tasks with an honest no-estimates state; and +"Assign work" routes through the ordinary task-create flow. + +**WS-28f — seats & roles matrix.** 🔴 **OWNER-GATE** for the write half: group membership +writes are already registered in `work_plan.md` §6 (d). Building the read matrix and the +propose-a-change path is agent-safe; applying a membership change is the owner's act. + +--- + +## 8. Later phases, named so their absence is a decision + +- **Onboarding** — checklists that provision accounts and first-week tasks. Would bind to + `colleague_onboarding.md`'s runbook and create tasks in the Projects app, not a new store. +- **Leave & attendance** — needs a policy model (accrual, approval chains) that nothing in + the platform has, and an approval path that should reuse the Action Broker inbox. +- **Hiring pipeline** — structurally a second CRM (candidates as leads, stages as statuses). + If wanted, it should reuse the `crm_*` shape rather than invent a third pipeline. +- **Performance, compensation, payroll** — out of scope, and each carries data-sensitivity + questions (§2's HR restriction is the *floor*, not the answer) that need deciding before + any of it is designed. + +--- + +## 9. Verification + +⚠️ Never `uv run pytest tests/unit/` bare — name the files. + +```bash +uv run pytest tests/unit/test_people_directory.py tests/unit/test_people_org_chart.py \ + tests/unit/test_people_capability.py tests/unit/test_people_migration.py \ + tests/unit/test_tasks_people_scoping.py tests/unit/test_org_access_control.py +cd workbench/control_plane && npx tsc --noEmit && npm test +``` + +`test_tasks_people_scoping.py` is in the list deliberately: WS-24 N4's 35 cases are the +fence around the HR projection, and any new read path over `gtd_people` must leave them +green rather than route around them. diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md index ee7c21318..c5ec92957 100644 --- a/ai-company-brain/specs/project_management_app.md +++ b/ai-company-brain/specs/project_management_app.md @@ -4,14 +4,15 @@ > module, sliced into every other Center) · **Created:** 2026-08-05 · **Updated:** 2026-08-06 > (owner pass — §8's three open questions are answered as **D-PM-8/9/10**; §7.1 gains the > Space→Center mapping step and WS-27b's done-whens grew with it) · -> **Status:** 🟢 **WS-27a + WS-27b + WS-27d BUILT** (2026-08-06, branch +> **Status:** 🟢 **WS-27a + WS-27b + WS-27d + WS-27e BUILT** (2026-08-06, branch > `claude/paca-research-task-management-a1f6zd`, PR #367) — migration `146_projects.sql` > (§3.1–§3.10), `feature:projects` registered on both sides, the `routes/projects/` API (§4 > minus `sync.py`) live behind the feature gate on the `gateway/db.py` seam, the ClickUp > importer with its Space→Center mapping plan (§7.1), and the `/projects` UI with its Center -> projections (§5). **Not deployed and never run** — the migration has not been applied -> anywhere and neither import endpoint has been executed against the live tenant. · -> **WS-27c, e, f, g: 🟡 SPEC, nothing built.** · +> projections (§5), and the personal lens (§3.11-§3.12, §6.1) on migration +> `147_projects_personal.sql`. **Not deployed and never run** — neither migration has been +> applied anywhere and neither import endpoint has been executed against the live tenant. · +> **WS-27c, f, g, h: 🟡 SPEC, nothing built.** · > **Owner:** vjvarada · **Board row: WS-27** > > **Verified 2026-08-06:** 140 hermetic cases across @@ -25,10 +26,16 @@ > Space, a plan that writes, and a re-import that duplicates), and WS-27d's six (an unknown > Center yielding an empty forest, `planDrop` never materialising, a board drop hard-coding > status, unpositioned tasks sorting to the top, a missing nav pane, a Center linking at a -> forked route). +> forked route). **WS-27e adds six more:** an overlay keyed by task rather than per member, +> a personal-only completion that leaves the board behind, `is_triaged` always true, an +> inbox that drops its personal-project arm, a disposition filter matching only the stored +> value, and a tickler that ignores `defer_until`. ⚠️ Two of those first **survived** and +> the fake was at fault, not the tests — it applied the inbox's arms unconditionally instead +> of keying them off the statement, the exact mirror failure `_projects_fakes.py`'s own +> docstring warns about. Found by mutation, not by review. > -> **Not built, on purpose:** no sync (WS-27c — blocked on BO-1a/BO-1b), no personal mirror -> (WS-27e), no automation/agent dispatch (WS-27f), and +> **Not built, on purpose:** no sync (WS-27c — blocked on BO-1a/BO-1b), no automation or +> agent dispatch (WS-27f), no `gtd_items` retirement (WS-27h), and > `schema.generated.sql` was NOT regenerated — it needs a migrated live DB and is stale > repo-wide, so it stays an owner-run chore (the WS-26a precedent). > @@ -287,7 +294,8 @@ on tasks). No engine 13. | `activities.py` | `GET /projects/tasks/{id}/timeline` · `POST /projects/tasks/{id}/comments` · `PATCH/DELETE /projects/comments/{id}` · `POST /projects/activities/{id}/revert` (field_change only) | | `admin.py` | statuses + types CRUD per root project (`RESTRICT` delete answers 409 naming the count in use) | | `views.py` | views CRUD · `PUT /projects/views/{id}/positions` (bulk upsert) | -| `me.py` | `GET /projects/assigned-to-me` — the personal lens's read (§6.1) | +| `me.py` | `GET /projects/assigned-to-me` — the flat "what is mine" read | +| `personal.py` (WS-27e) | `GET /projects/my/inbox` · `GET/POST /projects/my/project` · `POST /projects/my/tasks` · `PATCH /projects/tasks/{id}/personal` · `POST /projects/tasks/{id}/{complete,defer}` · `GET /projects/my/contexts` | | `mapping.py` (WS-27b) | no routes — the three suggestion signals and their combination, kept apart from the importer because a proposal and an application are different acts (D-PM-10) | | `import_clickup.py` (WS-27b) | `POST /projects/import/clickup/plan` (proposes a Center per Space, writes nothing) · `POST /projects/import/clickup` (applies the confirmed mapping) | | `sync.py` (WS-27c) | `POST /projects/sync` · `GET /projects/sync/status` · `GET /projects/sync/conflicts` | @@ -361,7 +369,10 @@ upsert per drop (the board's cross-column drag patches whatever field `column_by ### 6.1 Personal tasks (`/tasks`) — the org↔personal seam this spec exists for The requirement: a `pm_task` assigned to a member appears in their personal GTD system, and -completing it in either place is one fact. Mechanism is **D-PM-6** (§8): the Tasks app's +completing it in either place is one fact. **Since 2026-08-06 that is true by construction +rather than by synchronisation** — there is one row, and the personal view is a lens over +it (D-PM-6 revised). What follows describes the superseded mirror; it is kept for the +reader who needs to know what was rejected. ~~Mechanism is the Tasks app's existing provider machinery mirrors `pm_tasks` where `lower(assignee) = user` into `gtd_items` as `source='SYNCED'` rows (internal provider `commandcenter`, no credentials, no broker gate — it is not an outward write). The GTD overlay (disposition, context, @@ -390,6 +401,17 @@ per-step input/output snapshots, due-date-offset triggers, the derived dependenc are **`workflows_app.md` backlog items** (single owner, D6); this spec records the demand and stops. +**Written down 2026-08-06 — `workflows_app.md` §13.** The demand is no longer only recorded +here as a sentence: the engine spec now carries a full Paca-referenced uplift backlog, +**U1–U8**, each with the Paca design, this engine's measured current state, and a done-when. +The mapping from this section is exact: **U1 is the `pm.update_task` node** (WS-27f's first +half) and **U7 is agent dispatch** (§6.4, WS-27f's second half); U2/U3/U6 are the switch, +step snapshots and due-date trigger named above; **U4** (task retargeting over +`parent|children|blocks|…`) is the item this section had not named and is what makes "when +every child is Done, move the parent to Done" expressible at all. Nothing in §13 is built — +it is the reference an implementer picks up, so WS-27f no longer has to re-derive the engine +work from Paca's source. + ### 6.4 Agents — assignment is dispatch Assigning `agent:` (WS-27f): the `pm.task.assigned` event carries the agent target; a consumer creates the run through the existing orchestrator dispatch (the same seam chat @@ -488,6 +510,25 @@ Final import + parity counts per Space · flip the sync to **pull-only mirror** edits still land; pushes stop) · a soak window where the org works in `/projects` · then stop the pull. +### 7.5 The `gtd_items` retirement (WS-27h) — the cost D-PM-6's revision accepted + +One store means the old one goes. Sequenced **after** WS-27e (which is the destination) and +independent of the ClickUp work, because it is a move between two tables we own: + +1. `/tasks` reads a **union** of `gtd_items` and `pm_tasks` during coexistence, so the app + keeps working while rows move. +2. Every `gtd_items` row migrates: `LOCAL` rows into the owner's personal project; `SYNCED` + rows onto their `pm_tasks` counterpart by `clickup_id`, with the GTD overlay landing in + `pm_task_personal`. The disposition vocabulary is **unchanged on purpose** (§3.12), so + this is a copy rather than a translation. +3. `items.py`'s 27 `user_id` predicates retire with the table they scope. They are untouched + by WS-27e, deliberately — the blast radius WS-14 C1 measured belongs to this ticket. +4. `gtd_projects`, `gtd_spaces`, `gtd_folders` retire with it; `gtd_people` does **not** — + that is the People Center's store (`specs/people_center_app.md`). + +⚠️ **Not started, and it is the largest single piece of WS-27 remaining.** Until it lands +there are two personal task stores, which is the state this decision exists to end. + ### 7.4 Retirement inventory (WS-27g, second half) System A ClickUp arm: `ingestion/sources/clickup/` (client, normaliser, webhook), `scheduler.py`'s ClickUp job, `scripts/clickup_sync.py`, `/webhooks/clickup` from @@ -547,16 +588,51 @@ slice. **Rejected:** `gtd_item_sort_key`-style single order (one global order ca N views) . **Cost:** one side table and materialise-on-first-drag semantics the UI must implement faithfully. -**D-PM-6 — The personal connection is the Tasks app's provider seam, run internally.** +~~**D-PM-6 — The personal connection is the Tasks app's provider seam, run internally.** `DECISION (agent-proposed, owner may overrule).` §6.1's mechanism: `pm_tasks` mirrored into -`gtd_items` as `source='SYNCED'` under an internal `commandcenter` provider — every GTD -feature works unchanged, the overlay contract already exists, and the mirror is in-DB -(cheap, transactional, no broker). **Rejected:** (a) a read-union inside `/tasks` (touches -the 27-predicate blast radius C1 already measured, and the GTD overlay has no home for -un-mirrored rows); (b) linking `gtd_items` rows by hand (two sources of truth with no -reconcile discipline). **Cost:** row duplication inside one database, and the internal -provider must be exempted from `_broker_gate` (it is not an outward write — assert that in -its tests). +`gtd_items` as `source='SYNCED'` under an internal `commandcenter` provider…~~ +— **SUPERSEDED 2026-08-06.** Kept struck rather than deleted because the replacement is +only legible against what it replaces: the mirror was the thing rejected, and a reader who +finds `pm_task_personal` without this will wonder why the obvious answer was not taken. + +**D-PM-6 (revised) — ONE task store. The personal manager is a lens, not a copy.** +`DECISION (owner-directed 2026-08-06: "the personal task manager should be a proper +extension of the project system … a cohesive whole that should fit within each other".)` + +`pm_tasks` is **the** task table. Three consequences, and they are the whole design: + +1. **Assignment is not a sync.** A task assigned to a member is the row in their inbox. + Completing it there completes it for the project at the same instant, because there is + one row and one status. The mirror would have had two rows for one fact, and every + feature built afterwards — search, calendar, agents, reporting, the weekly review — + would have had to know about both. +2. **Private work is a personal project.** An ordinary `pm_projects` row carrying + `personal_owner`, granted to that one address (§3.11). Nothing about tasks, boards, + timelines, automation or agent dispatch needs a special case; it is a project whose + grant happens to name a person. Personal projects are excluded from every *team* read — + "My tasks" is not a department — which is presentation, not access. +3. **The GTD overlay is per-member** (§3.12, `pm_task_personal`). Two people assigned the + same task hold different dispositions: the person doing it says NEXT, the person who + delegated it says WAITING. A single column on `pm_tasks` could not express that, and it + is what delegation looks like rather than an edge case. + +**Rejected:** (a) the mirror, above — cohesion was the owner's stated requirement and a +mirror is by construction two things; (b) keeping `gtd_items` for private todos and merging +in the UI (owner-answered: two task tables forever, and every future feature has to handle +both — the seam that quietly drifts); (c) rewriting `/tasks` onto `pm_tasks` in one pass +(same end state, but ~11.8k lines and 68 endpoints at once, and a regression there breaks +the owner's daily driver). + +**Cost, and it is real:** `gtd_items` becomes legacy and needs its own retirement +(**WS-27h**, §7.5) — a second retirement project running beside ClickUp's. `/tasks` reads a +union of both stores until it lands. The 27 owner-scoped predicates in `items.py` are +untouched by this ticket and are WS-27h's problem, deliberately. + +**A property worth stating because it falls out rather than being built:** `disposition` is +NULL until a member triages, and the read *derives* one from the task's status. So "never +looked at" and "deliberately filed to INBOX" stay distinguishable — which is the only +question the Weekly Review exists to ask, and a column defaulting to `'INBOX'` would have +destroyed it silently. **D-PM-7 — Sync conflicts: three-way merge, newest-wins per field, conflicts logged to the timeline.** `DECISION (agent-proposed, owner may overrule).` §7.2. **Rejected:** whole-row @@ -682,15 +758,116 @@ and cross-column drags patch the `column_by` field; (3) nav/access registration slice pre-filters by its group — with a vitest asserting the `?center=` param filters presentation only. -**WS-27e — personal-task connection + people binding.** 🟢 AGENT-SAFE. -Done when: (1) an assigned `pm_task` appears in the assignee's `/tasks` inbox as a -`SYNCED` row with the correct GTD disposition mapping; (2) re-sync never clobbers the -overlay (the existing contract's test extended to the internal provider); (3) completion -round-trips both directions; (4) the internal provider is asserted broker-exempt; (5) -assignee suggestions read the capability layer with N4's projection intact. - -**WS-27f — automation + agent dispatch.** 🟢 AGENT-SAFE (the node types land in -`workflows_app.md`'s tree per D6 and are recorded there in the same PR). +**Authoring landed 2026-08-06** (`lib/assignees.ts` + edits to `page.tsx`, `ProjectTree`, +`TaskPanel`; 17 vitest cases, 7 mutants red). WS-27d shipped a UI that could **read and +drag but never create**: `createProject`, `createTask` and `setAssignees` existed in the +client and were wired to nothing, so a member could only work with rows a ClickUp import +had put there. Four surfaces close it, each placed where the answer already is: + +- **New department** from the sidebar header, **new subproject** from a `+` on the node + itself — the parent is on screen, and a dialog that asks "which parent?" is how a + fifty-node tree acquires mis-parented rows. +- **New task** from a one-field row above the board. Status is deliberately **not sent**: + the API picks the project's default (`create_task`), so the browser never has to know + which lane a new task starts in. +- **Subtask** from the task panel. A subtask is a task with a parent (§3.5) — one endpoint, + one table, so it inherits statuses, timeline and assignment whole. +- **Assignees** as removable chips plus one input. This is where **D-PM-4 stops being a + schema note**: an agent and a person go in the same field, and the only difference on + screen is an icon. Handing work to an agent is now literally the same gesture as handing + it to a colleague — which is the precondition for WS-27f's dispatch being reachable at + all, since `pm.task.assigned` is what it keys off. + +Two details worth keeping: `withAssignee` returns the **same array** when the assignee is +already present, and the caller skips the PUT on that identity — a re-assert must not emit +`pm.task.assigned` and re-dispatch an agent run. And `parseAssignees` splits on commas, +semicolons and newlines but **never on spaces**, because a pasted `Priya ` +would otherwise shred into tokens that assign work to nobody. + +**WS-27e — the personal lens (one store).** ✅ **BUILT 2026-08-06** +(migration `147_projects_personal.sql`, `routes/projects/personal.py`; 31 hermetic cases, +6 mutants red). **Its shape changed with D-PM-6's revision** — this was specced as a mirror +into `gtd_items` and is built as a lens over `pm_tasks`, so the done-whens below are +restated against what was actually built rather than what the mirror would have owed. +Done when: (1) an assigned `pm_task` appears in the assignee's inbox **with no sync** — +same row, same id — with the correct derived disposition; (2) a member's triage cannot move +the team's board and a status change cannot overwrite a stated disposition, both proven +structurally; (3) completing from the inbox moves the shared status and writes the +transition's three effects; (4) two assignees hold independent dispositions; (5) a personal +project is created once, granted to its owner alone, and excluded from every team read; +(6) no route here accepts a `?member=` in any form. + +**The surface landed 2026-08-06** (`src/app/projects/components/MyWork.tsx` + +`lib/mywork.ts`; 17 vitest cases, 7 mutants red). WS-27e had shipped API-only, which meant +the cohesion the revision bought was true in the schema and invisible to a member. **"My +work" sits above the project tree in the same app** — not a second surface and not a second +nav entry, because a personal lens reached from somewhere else re-teaches exactly the split +D-PM-6 was revised to remove. Four decisions worth recording, each of which could +reasonably have gone the other way: + +- **Four lanes, not eight.** `INBOX | NEXT | WAITING | SOMEDAY` are work states and get + lanes; `PROJECT | REFERENCE` are filing states and collapse into one "Filed" lane shown + only when occupied; `DONE | TRASH` the endpoint already excludes. Eight lanes would make + the daily view a filing cabinet. +- **Empty work lanes still render.** "You have triaged nothing into today" is a real and + useful state, and a lane that vanishes when empty cannot say it. Only "Filed" hides. +- **Undated tasks sort BELOW dated ones.** A task nobody dated is not more urgent than one + due tomorrow, and the opposite order is how a personal list stops being read. +- **Untriaged is stated in the row, not implied by a missing badge**, and counted in the + header. That count is the Weekly Review's whole question and is only answerable because + the server derives dispositions instead of storing them on first read (§3.12). + +Completing from a row calls `POST /tasks/{id}/complete`, which moves the **shared** status +— the checkbox carries a title saying so. Triage buttons call +`PATCH /tasks/{id}/personal` and cannot touch a shared field. One repair the surface forced: +`TaskPanel` previously read the *selected project's* statuses, which is wrong for a task +opened from My work — it may belong to any project the member is assigned into — so the +panel's statuses are now resolved from the task's own root project. + +**WS-27f — automation + agent dispatch.** ✅ **BUILT 2026-08-06** +(`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in +`workflows/engine/`, `PM_EVENT_TOPICS` in the catalog; 34 hermetic cases, 10 mutants red). +Both halves of `workflows_app.md` §13 — **U1** the task-mutation node, **U7** dispatch. +The node types land in `workflows_app.md`'s tree per D6 and are recorded there. + +**Six decisions worth reading before changing any of it:** + +- **The engine imports a service, not a route.** `apply_task_patch` is transport-free and + reuses `apply_status_transition`, `update_row`, `record_activity` — so an automation's + edit is *indistinguishable in validation* from a human's PATCH and lands the same + timeline row. That is Paca's "mutate through the ordinary service" rule as code. +- **Status is named, never keyed** (`"Done"`, or the category as a fallback). Statuses are + per-project rows, so a graph pinned to one project's status UUID could only ever automate + that project — the opposite of what an automation is for. An unknown lane fails with the + project's actual lane names in the message. +- **A `pm_task` node is NOT write-class.** The `write_without_approval` publish gate fires + for `tool` nodes reaching *external* systems; an internal task move must not need an + approval step. That exemption is now pinned by a test rather than true by accident. +- **"Already in target state" writes nothing** — and the test asserts **no `UPDATE` is + issued**, not merely that no activity was written. `update_row` stamps `updated_at`, so a + redundant write is invisible in a diff while leaving the task looking freshly touched; + an automation firing on `pm.task.updated` would bump every task it inspected, forever. +- **Assignment is dispatch, from a sink.** `PUT /tasks/{id}/assignees` emits and returns; + `agent_dispatch.on_event` is registered beside the workflows dispatcher. A slow or broken + agent therefore cannot fail the act of assigning somebody a task. Only **newly added** + assignees dispatch — `set_assignees` emits the added set, so a re-assert cannot start a + second run, and both sides say so. +- **The handoff activity is committed BEFORE the run starts** (Paca's + `agent.session.started`), and the failure path writes too. A dispatch that fails silently + leaves a session that appears to be running forever and nobody knows to pick the work up. + +**One engine defect found and fixed:** `templating.resolve_value` keeps an unresolvable +`{{ref}}` **as-is at run time by design**, and `{{trigger.missing}}` passes the publish gate +because its *root* is legal. The literal would have reached Postgres as a would-be uuid and +come back "Task not found", sending the maker to look for a task rather than at their +reference. The node now fails with `task id did not resolve: '…'`. + +Done when: (1) an event-triggered workflow mutates a task and the target carries a +`pm_activities` row actored `system:workflow:`; (2) unknown field and missing target +both fail at **publish** with named issues; (3) re-running against a task already in the +target state records a skip and writes nothing; (4) the node is served by +`GET /workflows/catalog` (D7); (5) assigning `agent:` starts a run whose session is on +the task timeline within the same request. Done when: (1) `pm.*` events reach `dispatch_event` (proven at the `emit_event` seam); (2) the `pm.update_task` action mutates through the ordinary service and stamps `system:workflow:`; (3) assigning `agent:` produces an orchestrator run, an @@ -698,6 +875,13 @@ immediate `agent_run` activity, and a closing activity on completion/failure; (4 `skill-projects` tool family lets an agent read/update its assigned task under its own identity, permission-intersected. +**WS-27h — `gtd_items` retirement.** 🟡 sequenced after WS-27e; the data move itself is +🔴 **OWNER-GATE** (it rewrites the owner's live task store). +Done when: (1) `/tasks` serves a union with no visible regression; (2) every `gtd_items` +row has a `pm_tasks` counterpart and the counts match per disposition; (3) the overlay +landed in `pm_task_personal` with dispositions preserved exactly; (4) `items.py`'s +owner-scoped predicates and the `gtd_*` task tables are gone. See §7.5. + **WS-27g — cutover + ClickUp retirement.** 🔴 **OWNER-GATE end-to-end** (final import, parity sign-off, sync flips, consumer repoint, token revocation, constraint-8 amendment — each registered in `work_plan.md` §6). @@ -723,3 +907,101 @@ SUT submodule), the migration asserted idempotent **statically** over its text, claims checked against `tests/unit/_schema_cascade.py`'s derived FK graph (the N8 lesson: never report a destroyed row as kept), and mutants for each new guard measured red and reverted byte-identical. + +--- + +## 11. ClickUp parity — the measured gap, and how it gets closed + +> **Added 2026-08-06** against the owner's standing requirement: *"I want to be able to do +> everything that I was doing from ClickUp and more."* WS-27g retires ClickUp, and it cannot +> honestly be called until this list is short. **Measured against the built tree** (twelve +> `pm_*` tables, 34 endpoints) rather than recalled — every "have" below is a table or a +> route that exists today. + +### 11.1 What is already there + +Hierarchy (departments → projects → subprojects → tasks → subtasks, two self-FKs) · +statuses-as-data with a semantic category · task types · assignees in one vocabulary for +people **and** agents · comments and a single activity timeline **with field-level revert** · +`blocks | relates_to | duplicates` links · per-view fractional ordering · board and list +surfaces · the personal lens · grant scoping with Center projections · the ClickUp importer · +a `pm_task` automation node and assignment→agent dispatch. + +Several of those ClickUp does **not** have — revert, agents as assignees, per-Center +projections of one board. That is the "and more" half, and it is already true. + +### 11.2 What is missing, in the order it hurts + +Ordered by *what stops somebody using this instead of ClickUp on a Monday*, not by how +interesting it is to build. + +| # | Gap | Why it blocks | Ticket | +|---|---|---|---| +| 1 | ~~**Attachments**~~ | — | **WS-27i ✅ BUILT 2026-08-06** | +| 2 | **Notifications + @mentions** — nothing notifies anybody | Assignment is silent. A tool nobody hears from is a tool nobody opens, and the whole assignment→agent chain assumes somebody noticed | **WS-27j** | +| 3 | **Filters, grouping and saved views** — `pm_views` exists; the UI has a board/list toggle and nothing else | "My open bugs in Ops, grouped by assignee" is a daily question with no answer. The **table is already there**, so this is a UI ticket, not a schema one | **WS-27k** | +| 4 | **Custom fields** — no definitions table, no values | ClickUp's signature feature. Paca's shape (`custom_field_definitions` + a JSONB column keyed by `field_key`) is proven and portable | **WS-27l** | +| 5 | **Tags** — refused Paca's bare JSONB array (research row 13) and nothing replaced it | The refusal was right and left a hole. A tag registry with rename/merge is the version worth having | **WS-27m** | +| 6 | **Bulk edit / multi-select** | Re-triaging fifty imported tasks one at a time is how an import gets abandoned — this one gates the migration itself | **WS-27n** | +| 7 | **Recurring tasks** | Every operations cadence is recurring. Without it those live in someone's head or in ClickUp | **WS-27o** | +| 8 | **Dependency and subtask UI** — `pm_task_links` and `parent_task_id` both exist, unreachable from the board | Data with no surface is a promise the product does not keep | **WS-27p** | +| 9 | **Calendar / timeline view** | The third view ClickUp users actually use, after list and board | **WS-27q** | +| 10 | **Global task search** | `?q=` exists on the list endpoint; there is no search surface | **WS-27r** | + +**Deliberately NOT on this list:** sprints (a stated non-goal, §1), time tracking and +checklists (Paca moved both out of core into plugins — the growth path is subtraction), and +Gantt. If any is wanted, it is a decision to record, not an omission to fix. + +### 11.3 Sequencing, and the one dependency that matters + +**WS-27n (bulk edit) gates WS-27g.** The cutover imports a real workspace, and an import +that cannot be re-triaged in bulk is an import somebody abandons halfway — leaving two live +systems, which is the exact state the retirement exists to end. Build it before the cutover, +not after. + +**1 → 2 → 3 are the daily-use tier** and should go first as a block: a member who can +attach a file, hear about an assignment, and filter their board can run a day here. 4-5 +(custom fields, tags) are the *modelling* tier — they change what a task can say. 6-10 are +reach. + +Every one is 🟢 **AGENT-SAFE** to build. The gates stay where they already are: running the +importer against production, confirming a Space→Center mapping, and the WS-27g cutover are +owner acts (`work_plan.md` §6), and nothing in §11 changes that. + +### 11.4 WS-27i — attachments (built 2026-08-06) + +Migration `150_projects_attachments.sql`, `routes/projects/attachments.py`, the Files +section of the task panel. 25 hermetic cases, 10 mutants red. + +**One file store, not two.** `gtd_attachments` already IS Paca's "central files registry" +(research §2.7) — owner, name, mime, size, path — so the bytes and the upload rules are +**imported** from the capture flow rather than copied. A second table with a second storage +directory would have meant two places to back up, two size limits to keep in step and two +answers to "is this extension allowed". + +**What differs is who may READ, and that is the entire reason for the join.** +`gtd_attachments` is owner-scoped end to end; `pm_task_attachments` makes a file readable by +anyone who can see a task it hangs off. Two consequences, both security properties rather +than conveniences: + +- **There is no attach-by-id endpoint.** Upload and attach are one call. A caller who could + name an arbitrary `attachment_id` could attach somebody else's private capture to a task + they own and read it back — privilege escalation dressed as a feature. +- **A personal capture stays unreachable here**, because it has no join row. + +Detaching **keeps the bytes**: the same file may hang off another task, and deleting the row +from under it would turn one person's tidy-up into somebody else's broken link. Detaching +something already gone is a no-op, not a 404 (Paca's lenient-removes lesson, research §6). + +**A bug caught before it shipped:** the projects BFF proxy re-serialised every POST as JSON. +A multipart upload would have failed `req.json()`, fallen into the `catch(() => ({}))`, and +reached the gateway **with no file at all — while still answering 201**. The proxy now passes +a non-JSON body through byte-for-byte. `workflows_app.md` §3.3b documents the identical trap +for HMAC-signed webhook bodies; this was that trap on the upload path. + +**A test that was asserting the wrong thing:** the first traversal test checked the file's +path on disk, which is safe *by construction* (`` — the supplied name never +reaches it), so a mutant removing `_safe_name` entirely survived. What that function actually +protects is the **stored name**, which is echoed into the descriptor, rendered in the UI, and +handed to `FileResponse(filename=…)` — i.e. into a `Content-Disposition` header, where +separators, quotes and newlines matter. Both properties are now asserted separately. diff --git a/ai-company-brain/specs/task_manager_app.md b/ai-company-brain/specs/task_manager_app.md index 98d365d47..d5206e5df 100644 --- a/ai-company-brain/specs/task_manager_app.md +++ b/ai-company-brain/specs/task_manager_app.md @@ -555,6 +555,40 @@ This is the explicitly-requested capability: not just *my* tasks, but **delegati --- +### 6.0 SCOPE NARROWED 2026-08-06 — this app is the PERSONAL lens + +`DECISION (owner-directed 2026-08-06: "the tasks app will only now be used for managing +personal tasks … we have a separate project view now, and the people will show up in the +people center instead.")` + +The **Projects** and **People** left-rail views are **removed**. What they showed now has a +whole app each: + +| Was | Is | +|---|---| +| Projects view — the ClickUp Space → Folder → List → Task → Subtask tree (§9 item 9) | **`/projects`** (WS-27). Departments, projects, subprojects, tasks, boards, per-Center slices | +| People view — the HR roster + person editor | **`/people`** (WS-28). Directory, person page, skills, capacity, org chart | + +**What did NOT change, deliberately.** `GtdProject` survives as an *association*: a task +still belongs to a project and every card still names it. What went is the **browsing** +surface. The clarify flow's Where axis still offers the local hierarchy, because filing a +personal task is a personal act. `gtd_people` is untouched — the People Center reads it. + +**Two things this broke and how they were handled.** `loadPeople()` had exactly one caller: +opening the People view. Deleting that view would have left the delegate picker and the +clarify Who axis silently offering nobody — a broken picker that reads as a company with no +people — so both now load the roster where they need it. + +⚠️ **One capability is unreachable and has no replacement yet: creating/editing a person and +uploading a résumé.** `PersonEditor` was the only UI for it. The API is untouched +(`POST/PATCH /tasks/people`, `POST /tasks/people/{id}/resume`, all on +`admin:members:manage`) and `taskStore.uploadPersonResume` still wraps it, so nothing was +deleted — but until the People Center grows the write half (**WS-28b-write**, filed in +`people_center_app.md` §7) an admin cannot do it from the product. Stated here rather than +discovered later. + +--- + ### 6.1 People & capabilities intelligence — the org-knowledge layer (✅ v1 shipped) > Added 2026-07-01 as a forward design note; **v1 shipped 2026-07-02** with the *actual* company data. `agent-project-manager`'s `agent-data/` (hr_structure.json + resume_profiles.json — 26 people, 11 departments, roles, org-chart + resume-extracted skills, capacity/load hours, ClickUp user ids) is snapshotted into **`infra/seed/hr/`** (phones stripped) and imported into **`gtd_people`** (migration `49_gtd_people.sql`) by **`scripts/import_hr_people.py`** (idempotent upsert by name; re-run to refresh — the source repo / HR system stays the source of truth). Served via **`GET /tasks/people`** (auth-gated, `q` searches name/role/department/skill); the clarify proposal is now **capability-aware** (skills word-boundary match + free-hours tiebreak → `suggested_assignee` with the person's real ClickUp id, so delegation pushes assign the actual user); the agent gained **`gtd_people(query)`**; the UI's delegation/assignee pickers hydrate from the org people. Remaining (below) = embeddings matching, live load sync from the PM tool, overload warnings, and richer org-structure reasoning. diff --git a/ai-company-brain/specs/workflows_app.md b/ai-company-brain/specs/workflows_app.md index 9a8f35471..5519a43d0 100644 --- a/ai-company-brain/specs/workflows_app.md +++ b/ai-company-brain/specs/workflows_app.md @@ -5,6 +5,7 @@ > **Slice 3 re-scoped 2026-08-03 (truth pass, §8.3).** The one-line Slice 3 asked for three things and **one of them is already shipped**: describe→generate→refine full-graph authoring landed as F14 (`39b1e17a`) and is **struck**. "Parallel fan-out" is also shipped (`engine/graph.py:17`; MAF's superstep scheduler routes it) — the unbuilt half is **fan-in/join**, restated as such. What genuinely remains is **fan-in/join (8.3b), loops (8.3c), and a template gallery (8.3a — nothing exists)**. Two owner decisions recorded the same day: Command Center is an **internal Fracktal tool** (§1.4) and **loops are approved** despite §11 R1 (§8.3c). > **Parent RFC:** [`docs/workflow-editor/README.md`](../../docs/workflow-editor/README.md) — stack selection (React Flow), the compile-to-MAF-Workflows decision, data model, editor UX, trigger taxonomy. Read it for *how*; this doc is *what, why, and why now*. Interactive mockup: `docs/workflow-editor/mockup.html`. > **Reference precedents:** [`task_manager_app.md`](task_manager_app.md) (app spec shape) · [`docs/app-workshop/README.md`](../../docs/app-workshop/README.md) §4.0 (the platform contract this app also enforces). +> **Engine uplift backlog — §13 (added 2026-08-06).** A code-verified read of Paca's automation engine ([`paca_pm_research_2026-08.md`](paca_pm_research_2026-08.md) §4–§6) against this engine, as eight scoped items **U1–U8** with done-whens, plus the `pm.*` binding that already ships and the five Paca features deliberately refused. §13 is **backlog, not built work**; it does not change Slice 3 (§8.3) or Slice 4 (§8.4). > **Policy amendment:** ADR-028 (see `system_architecture.md`) amends ADR-014 and `project_plan.md` C-09 / §2 non-goals — see §10. --- @@ -219,6 +220,8 @@ Aligned to RFC §9, resequenced so each slice ships value: - **Slice 3:** three items — **8.3a templates**, **8.3b fan-in/join**, **8.3c loops**. Fully specified with per-item acceptance, gate labels and verification in **§8.3**; the old one-line version was 16 words and asked for one thing that already shipped. (Workflow-as-tool for the orchestrator shipped early — F13, Slice 2.) - **Slice 4:** blocked. Named dependencies and the reason in **§8.4** — it is *not* "post-BO‑20" in the vague sense. +**Not a slice: §13 (Paca engine uplift, U1–U8).** Deliberately kept out of the slice sequence — the items are independent of one another and of the slices, and folding them into Slice 3 would repeat the mistake §8.3's truth pass corrected (a one-line slice hiding several unrelated problems). One of them, **U1**, is the near-term one: it is WS-27f's first half and the only item another workstream is waiting on. **U5 is blocked on 8.3b** and must not be started before it. + ### 8.3 Slice 3 — specified (truth pass, verified against code 2026-08-03) **What was struck.** *"Describe→generate→refine full-graph authoring"* is **DONE — delivered by F14 in commit `39b1e17a`** ("feat(workflows): Workflow Copilot + semantic capability search"). `POST /workflows/{id}/copilot` (`copilot.py:1-12`) emits the **FULL updated graph** — the system prompt says so literally at `copilot.py:51` (`"graph": {...} // FULL updated graph, or null if no change`) — with a named-issue repair round against the same validators publish uses, and auto-creates the modules the graph needs. §2 already records this twice (F12 *"Superseded by F14"*, F14 *"Must (shipped)"*). Dispatching it would have sent an implementer to rebuild a live endpoint. **Do not re-open it.** @@ -343,3 +346,189 @@ Anything in Slice 4 that looks reachable today is reachable only because its dep ``` Green means **73 passed** in CI (`ubuntu-latest`). On Windows the honest expectation is **69 passed / 4 failed**, all four being the `preexec_fn` module-sandbox defect catalogued in §8.3 — see that section before reporting a regression. Items 1–5 above are Slice-1/2 criteria and are met; Slice 3's criteria are per-item in §8.3a/b/c, not here. + +--- + +## 13. Paca automation-engine reference — the uplift backlog and the WS-27 binding + +> **Added 2026-08-06.** Source: [`paca_pm_research_2026-08.md`](paca_pm_research_2026-08.md) §4 (automation graph), §5 (agent dispatch), §6 (MCP tool design) — a code-verified read of `Paca-AI/paca` @ v0.11.0, Apache-2.0. **Reference-only:** that file owns no work; this section owns the work it implies for *this* app. Paca's stack (Go/chi + sqlx, Valkey streams, OpenHands sandboxes) does not survive translation — **we take schema shapes and execution discipline, never code.** +> +> **Why this section exists.** WS-27 (`project_management_app.md`) needed automation and, per **D6 (`work_plan.md` §3)** and ADR-028, did not build one — `/workflows` is the only engine, so a Projects-owned rules engine would have been a single-owner violation. WS-27 therefore contributes *events and node types* and files the engine-shaped findings **here**, where the engine is owned. Everything below is backlog with an acceptance standard, not built work. Nothing in §13 is claimed as shipped. +> +> ⚠️ **Two Paca documents will mislead a re-deriver.** Paca's `docs/architecture/repository-structure.md` says "Go + Gin" (the API is **chi v5**), and `docs/architecture/automation-workflows.md` documents the **v0.10 design that was dropped** — migration `000027` `DROP TABLE … CASCADE`d it and replaced it with the graph model described here. Re-derive from `000027_add_automation_graph.sql` and `worker/automation_consumer.go`, **never** from Paca's architecture docs. + +### 13.1 The binding that already exists (verified against code, 2026-08-06) + +The Projects app is **already wired to this engine**, on the seam the ClickUp receiver uses. No new bus was built, and none should be: + +| Link | Where | State | +|---|---|---| +| Projects emits | `routes/projects/core.py:909` `emit()` → `ingestion.event_hooks.emit_event("projects", …)` | **Shipped.** Best-effort by construction — a workflow that cannot run must never fail the task write that triggered it | +| The seam is registered | `gateway/main.py:1122-1125` — `register_event_sink(workflows.triggers.dispatch_event)` at startup | **Shipped** (pre-existing; WS-27 added no transport) | +| The engine listens | `triggers.py::dispatch_event` — one run per **published** workflow whose enabled `kind='event'` trigger matches `(source, event_type)` | **Shipped** | +| The engine can act on tasks | *(nothing)* — the node catalog (`engine/graph.py:35-45`) has `trigger, agent, tool, module, condition, set, approval, wait, output`. **No task-mutation node type exists.** | **U1, below** | + +**The eleven `pm.*` topics live on the seam today** (`source="projects"`): `pm.task.created`, `pm.task.updated`, `pm.task.status_changed`, `pm.task.assigned`, `pm.task.moved`, `pm.task.deleted`, `pm.task.comment_added`, `pm.project.created`, `pm.project.updated`, `pm.project.moved`, `pm.project.deleted`. An event trigger with `{"source": "projects"}` and no `event_type` matches all eleven (`event_trigger_matches`, `triggers.py:35`). + +So the engine can already **hear** about projects and cannot yet **act on** them. That asymmetry is the whole near-term gap, and it is U1. + +**Two caveats on the existing path, both pre-existing and both recorded elsewhere** — restated because a `pm.*` automation inherits them: a workflow fires only when **published** (`triggers.py:57`), and `dispatch_event` **swallows every error** by design, so a failed dispatch is currently invisible (BO‑20b slice 2's scope, §8.4). + +### 13.2 What Paca's engine is, in one paragraph + +`automations` (`draft|active|archived`) + `automation_nodes(kind ∈ trigger|condition|action, type, config jsonb, pos_x, pos_y)` + `automation_edges(source_handle NULL)` + `automation_runs` + `automation_run_steps` (per-node `input_snapshot` / `output_snapshot` / `error`), plus at-most-once bookkeeping tables for due-date and cron fires and hashed webhook tokens (`pacahk_` prefix; rotation revokes the prior token). **One JSONB `config` per node** serves 9 trigger types, conditions, 3 action types, and unbounded plugin-contributed types with no wide null-column set. The consumer (`worker/automation_consumer.go`, 1602 lines) reads the **ordinary activity stream** — the engine is "a sibling reader, not a special case wired into the HTTP handler" — maps field changes to candidate trigger types (zero candidates ⇒ cheap ack), re-fetches the authoritative task, walks the graph with a `visited` set, records a step row per node, and **mutates through the ordinary task service** so an automation's edit gets identical validation and writes an `automation.applied` activity with a nil actor. + +Structurally we already agree with all of that: our graph is DB-persisted config (D1), our node config is JSONB, our runs are rows, and our engine reads the same event seam the receivers write. **The gaps are in the vocabulary and the trace, not the architecture** — which is why this is an uplift backlog and not a rewrite. + +### 13.3 The uplift items + +Each item states Paca's design, **what this repo actually has today** (cited, verified — not assumed), the gap, and a done-when written to §8.3's standard: *an assertion a test can make, on a validator or a status code, never a screenshot.* + +Sequencing note: **U1 is the only item WS-27 is waiting on.** U2–U8 are independent of Projects and can be picked up in any order. None of them is a Slice-3 item — §8.3a/b/c stand unchanged, and U5 in particular is **downstream of 8.3b** and must not be started before it. + +#### U1 — A task-mutation action node (`pm_task`) ✅ **BUILT 2026-08-06** · *WS-27f* + +**Paca:** three action types only — `update_task`, `trigger_ai_agent`, `call_api`. `update_task` is itself a **consolidation**: it merged five prior single-field actions (`set_status`, `set_assignee`, `set_priority`, …) into one multi-field patch. That consolidation is the lesson, recorded by Paca as an explicit one; a per-field node set is the thing to *not* build. + +**Here:** no node type touches an internal app. The `tool` node reaches *external* systems through the Integration Registry; there is no in-platform equivalent, so an automation can currently observe a `pm.*` event and do nothing about the task. + +**Design constraints, non-negotiable:** +- **One multi-field node, not one per field** — Paca's consolidation lesson, adopted before we make its mistake. +- **Mutate through the ordinary task service**, never raw SQL. The automation's edit must take the same validation path a human's PATCH takes (status-transition effects, cycle guards, `pm_activities` write) — this is Paca's discipline and it is also how the edit stays auditable. +- **Actor string `system:workflow:`**, inside the existing `email | agent:` vocabulary (research §2.5 / table row 5). A new actor shape would fork the vocabulary the whole platform reads. +- **"Already in target state" check before writing** (research §9): what makes a crashed walk safe to retry. Skip-because-already-there is a recorded step outcome, not a silent no-op. +- **Write-class?** A `pm.*` mutation is an *internal* write, so it does **not** need the `write_without_approval` publish gate that outward integration writes need (§3.2). State this explicitly in the node's catalog metadata, because the default reading of "write" would gate it and nobody wants an approval on "move the task to Done". + +**Done when:** +1. A published workflow triggered by `{"source": "projects", "event_type": "pm.task.status_changed"}` mutates a second task through `pm.update_task`, and the target task carries a `pm_activities` row whose actor is `system:workflow:`. +2. The node validates at publish: unknown field ⇒ named `GraphIssue`; missing task reference ⇒ named `GraphIssue`. Neither reaches run time. +3. Re-running the same node against a task already in the target state records a step outcome (skipped/no-op) and writes **no** activity row — asserted, so idempotency is pinned rather than hoped for. +4. The node appears in `GET /workflows/catalog` (D7 — served, never hard-coded in the UI). +5. An automation edit is **indistinguishable in validation** from a human edit: a transition the API would refuse from a human is refused from the node too, with the same error. + +**Built 2026-08-06 — what actually shipped, and the two places it differs from the sketch above.** +The node type is **`pm_task`** (engine node types are bare words; `pm.update_task` was the +Paca-side name). Its config is `{task_id, fields:{…}}` and it writes through +`routes/projects/automation.apply_task_patch`, a transport-free service that reuses +`apply_status_transition`, `update_row` and `record_activity` — so the "mutate through the +ordinary service" rule is structural rather than a convention. + +Two design points that were NOT in the sketch and are load-bearing: + +- **Status is set by lane NAME, with the semantic category as a fallback** — never by + `status_id`. Statuses are per-project rows, so a graph carrying one project's status UUID + could only ever automate that project. An unknown lane fails with the project's actual + lane names in the message rather than "status not found". +- **`update_task` takes no actor.** The engine says *what* to change; the wiring + (`build_node_services(actor, workflow_id)`) decides *who*, and for an automation that is + always `system:workflow:` — never whoever tripped the trigger. Passing an + actor the implementation would have to ignore is how the two answers start disagreeing. + +**The write-class exemption is now pinned by a test.** `write_without_approval` +(`graph.py:216`) only fires for `tool` nodes, so a `pm_task` node is un-gated — correct, and +previously true only by accident. `test_a_task_node_does_NOT_need_an_approval_ancestor` +makes it deliberate. + +⚠️ **One engine defect surfaced and fixed while building this, and it is not +`pm_task`-specific in nature.** `templating.resolve_value` keeps an unresolvable `{{ref}}` +**as-is at run time by design** (its docstring says so: "design-time validation is the place +that flags it"), and `{{trigger.missing}}` *passes* `validate_graph` because its **root** is +legal. The `pm_task` node now refuses a task id that still contains `{{`. **Every other node +type has the same exposure** — the `agent` node will happily send a literal `{{…}}` as its +message — so this is worth a general fix; it is recorded here rather than fixed globally +because widening it touches every handler and belongs to its own ticket. + +**Extraction note for whoever reads `handlers.py` next:** `execute_node` sat at exactly the +C901 ceiling (15), so adding this branch required paying for it. `_execute_set` was +extracted **unchanged** alongside `_execute_pm_task`; the golden trajectory eval covers both +and stayed green, and the ceiling was not raised. + +#### U2 — N-branch switch conditions ✅ **AGENT-SAFE** + +**Paca:** a condition is an ordered N-branch switch — first-true-wins, with a **reserved `else` handle**. Each branch is a **flat single comparison** (field × operator); there is deliberately no AND/OR nesting. A `validOperatorsByField` table rejects unimplemented field/operator combos **at validation time** instead of silently evaluating false at run time. + +**Here:** the condition node is exactly two handles. `BRANCHING_TYPES = frozenset({"condition"})` (`graph.py:50`) and the edge check refuses any handle that is not `"true"` or `"false"` (`graph.py:275-278`). Evaluation is a single closed-vocabulary comparison — `evaluate_condition(left, op, right)` (`handlers.py:81`), no `eval()`, operators `equals|contains|is_empty|…` and their negations. + +**The gap is fan-out of branches, not the comparison.** Our flat-single-comparison choice already matches Paca's; a five-way status router today needs four chained condition nodes, which is unreadable on canvas and quadruples the node count in run history. + +Two things to steal beyond the shape: **first-true-wins ordering must be explicit in the serialized run-model** (relying on JSON array order in the edit-model repeats 8.3b's "edge order is not stable" defect), and **`else` must be reserved** — a maker branch literally named `else` has to be rejected at validation. + +**Done when:** a condition node with N branches + `else` validates, publishes, and routes down exactly one handle; branch order is explicit in `workflow_versions.serialized` and survives a re-draw of the edges; an unreachable branch (one no comparison can select) is a named non-blocking publish `warning`; and the existing two-handle graphs still validate byte-identically (the change is additive — pinned by the existing engine tests staying green unchanged). + +#### U3 — Per-node **input** snapshots in run history ✅ **AGENT-SAFE** · *closes a real G6 gap* + +**Paca:** `automation_run_steps` persists **both** `input_snapshot` and `output_snapshot` per node, plus `error`. + +**Here:** `workflow_runs.node_results` (`132_workflows.sql:81`) is a JSONB **column**, one slot per node: `{status, output|error, duration_ms}` (`runner.py:44,180`). **The input is not recorded.** F9's drill-in therefore replays what each node *produced*, never what it *received*. + +**Why this matters more than it sounds.** §2 F9 and §1.2 G6 both promise "per-node **inputs**/outputs"; the schema does not deliver the first half. When a `{{ref}}` resolves to something unexpected, the run history shows the wrong output and gives no way to see the wrong input that caused it — the single most common debugging question a maker will ask, and today it is unanswerable without re-running. + +**Done when:** each entry in `node_results` carries the node's resolved config/input alongside its output; **secret-shaped values are redacted on the way in** (reuse `SECRET_PATTERNS`, `graph.py:52-58` — a resolved integration argument must never be persisted in a run row); the payload is size-bounded like the module output bound; the editor's history drill-in shows input and output side by side; and **old runs without inputs still render** (the field is additive and nullable — asserted against a fixture of the current shape, because a migration that breaks history drill-in for existing runs is worse than the gap). + +#### U4 — Task retargeting (`self | parent | children | blocks | …`) ⚠️ **AGENT-SAFE, but sequence it after U1** + +**Paca:** a condition *or* an action can aim at `self | parent | children | blocks | is_blocked_by | relates_to | duplicates | other(id)`. Multi-valued targets **fan out** — an action runs per resolved task; a condition combines via all/any. + +**Here:** nothing analogous, because U1 does not exist yet. Our data model already supports every one of those targets: `pm_tasks.parent_task_id` (self-FK) and `pm_task_links` with the `blocks|relates_to|duplicates` vocabulary (migration `146_projects.sql`). + +**This is what makes automations useful rather than toy-like** — "when every child is Done, move the parent to Done" is the canonical PM automation and needs `children` + an all-combiner. Note it is **not** the same feature as 8.3b's graph-level fan-in: retargeting fans out over *rows*, inside one node; 8.3b fans in over *edges*, between nodes. Do not conflate them, and do not let one ticket claim both. + +**Done when:** each target keyword resolves to the right row set with a bounded fan-out cap (a named failure when exceeded, never an unbounded walk); a condition over a multi-valued target combines by an explicit `all`/`any` chosen in config, never an implicit default; a target resolving to **zero** tasks is a recorded no-op step, not an error; and each fanned-out action records its own step outcome so history shows *which* children were touched. + +#### U5 — Stateless AND-join (`predecessor_done`) 🚧 **BLOCKED on §8.3b** + +**Paca:** the `predecessor_done` trigger is an AND-join over watched tasks that is **stateless** — it re-derives every watched task's live status *category* on each fire rather than keeping a counter, which makes it idempotent under at-least-once redelivery. The **dependency map UI is derived on read** from active `predecessor_done` nodes and never separately maintained. + +**Here:** the engine refuses fan-in outright — `"a node may have only one incoming edge (v1)"` (`graph.py:303-311`). + +**Why blocked, precisely:** §8.3b must first settle the merge shape, quorum rule, and pause/replay interaction for graph-level joins. A `predecessor_done` trigger built before that decision would either invent a second join semantics or quietly constrain 8.3b's. **Do not start U5 before 8.3b is decided.** + +The transferable discipline is the *statelessness*, and it is transferable independently: **re-derive, don't count.** Our `pm_task_statuses.category` CHECK (`backlog|todo|in_progress|done|cancelled`) is exactly the machine-readable semantic Paca's join reads. A counter column would drift under redelivery; a live re-derivation cannot. + +**Done when:** 8.3b has landed; the trigger holds no persisted counter (asserted by schema — there is no column to drift); firing the same source event twice produces one downstream effect; and the dependency map is computed on read from active nodes with **no** maintained table. + +#### U6 — At-most-once fire bookkeeping for time-based triggers ✅ **AGENT-SAFE** + +**Paca:** dedicated bookkeeping tables record due-date and cron fires so a redelivery cannot double-fire. + +**Here:** we solve the cron half differently and, on the evidence, **better** — CAS on `workflow_triggers.last_fired_at` means exactly one worker wins each tick, clock skew included, and downtime collapses to one catch-up fire rather than a storm (§3.3a, D6). **No table needed; do not add one.** + +**The genuine gap is the other half: there is no due-date trigger at all.** Paca's `due_date_reached` (offset minutes, polled) has no equivalent here, and "ping the assignee 24h before a task is due" is the single most-requested PM automation there is. It belongs to the **schedule scanner** (`scheduler.py`) — the loop that already polls and already CAS-claims — not to the event seam, because no event fires when a due date merely *arrives*. + +**Done when:** a `pm.task.due_in(offset_minutes)` trigger fires once per task per offset (asserted across a simulated scanner restart, which is where a naive implementation double-fires); changing a task's due date re-arms rather than double-firing; and the claim rides the existing CAS discipline rather than introducing a second one. + +#### U7 — Agent dispatch on assignment ✅ **BUILT 2026-08-06** · *the second half of WS-27f* + +**Paca:** `trigger_ai_agent` action → `{message, member_id}` → the assignment consumer writes an `agent_conversations` row and appends to `paca:agent:triggers`; an `agent.session.started` activity lands **on the task** so the handoff is visible in the timeline immediately; the agent then writes back **through the ordinary API under its own identity** (API key + `X-Agent-ID`, permission-checked as its own project member — a stated "Boundary Rule": the AI service never writes to Postgres directly). + +**Here:** the `agent` node already dispatches to the orchestrator (`orchestrator.executor.run_agent`, `source="workflow"`). **The gap is task context, not dispatch** — assigning a `pm_tasks` row to `agent:` emits `pm.task.assigned` and stops there. + +Two things to carry: the **session must be visible as a task activity** the instant it starts (not only in run history, which nobody browsing a task will open), and the agent's write-back must go through the ordinary gateway API under its own identity. We are stronger than Paca on the second — `EffectiveAccess.intersect()` already narrows an agent by the acting member; Paca has no equivalent — so this is adoption of a *shape* we can enforce harder than the source does. + +**Owner constraint, already settled and not re-openable here:** an agent's edit to a ClickUp-linked task is treated **exactly like a human edit** (owner decision, WS-27b). No approval queue for agent pushes. + +**Done when:** assigning a task to `agent:` starts an orchestrator run whose session is visible as a `pm_activities` row on the task within the same request; the agent's write-back arrives through the ordinary task API under its own actor string; and a failed dispatch marks the activity failed rather than leaving a session that appears to be running forever. + +#### U8 — Agent-facing tool surface: the collapse lesson ✅ **AGENT-SAFE** · *design guidance, not a ticket* + +**Paca's MCP server** exposed **16 automation tools**, found it confused calling agents, and deliberately collapsed to **4** (`get/create/update/delete_automation`) taking rich nested payloads with **per-item outcomes** (one bad entry doesn't block its siblings) and **lenient removes** (removing something absent is a no-op) so a partial-failure retry is safe. Also: **internal UUIDs are never agent-facing** — nodes are addressed by task id, transitions by status id, and the tool layer resolves, so the agent never needs a read round-trip before it can write. And `ListTools` is **permission-filtered** — the tool list is computed from the caller's actual permissions. + +**Here:** F13 already chose the collapsed shape independently — `list_workflows`/`run_workflow`/`get_workflow_run`, three generic tools rather than one per workflow, explicitly "so the catalog scales without bloating agent tool schemas". Paca's experience is **confirming evidence for a decision already made**, and the reason this item is guidance rather than a ticket. + +What is *not* yet adopted, and should bind any future agent-facing tool in this app: per-item outcomes on batch payloads, lenient removes, no internal UUIDs in the agent-facing contract, and permission-filtered tool listing. **Record it here so the next tool surface starts from the collapsed design rather than rediscovering it at 16 tools.** + +### 13.4 What is deliberately refused + +Not oversights — decisions, so a future reader does not "fix" them: + +| Paca feature | Refused because | +|---|---| +| A separate automation engine + its own tables | **ADR-028 / D6 (`work_plan.md` §3)** — `/workflows` is the only engine. Everything above is an uplift *to this app*, never a sibling | +| `call_api` action node | We have integration `tool` nodes resolved through the Registry (§3.2). Paca's own `call_api` stores headers visible to any project reader — a gap its source comments acknowledge. **Do not reproduce it** | +| A worker process consuming an activity stream | D6 (`workflows_app.md` §9): supervised asyncio loops in the gateway, not a worker daemon. BO‑20's durable consumer is the platform's answer, and it already exists in shape | +| WASM plugin runtime contributing node types | ADR-028: no second runtime. App Workshop + the skills registry answer this concern differently and already | +| Tags as a bare JSONB array | Research table row 13 — the weakest part of Paca's model; refused for Projects and equally here | + +### 13.5 Where the numbers are + +Effort-shaped grouping for whoever picks this up, so the section can be sequenced without re-reading it: **U1 is WS-27f's first half and the only item anything is waiting on. U7 is its second half.** U2 and U3 are self-contained engine work with no cross-app dependency — U3 is the one that closes a promise §1.2 G6 and §2 F9 already make in writing, which arguably ranks it first of the two. U4 waits on U1 by construction. U5 waits on §8.3b by decision. U6 is independent and is the highest-value *new trigger*. U8 is guidance that binds the next tool surface and consumes no ticket. diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index 73151c249..fba68a184 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -148,7 +148,8 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. | WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. | | WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. | | **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT + DEPLOYED** · ✅ **d read half BUILT + DEPLOYED** · 🟢 **d-email, d-autolead, d-write dispatchable** | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **migration 144 applied on prod and `/crm` live as of 2026-08-06**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Measured 2026-08-06: mig 145 is applied on prod and the BACKFILL HAS RUN — 737 orgs / 1,189 contacts / 1,516 leads / 551 deals / 1,909 notes, zero dirty rows, zero unmatched owners; the §7.1 pre-flip curl confirmed the tenant honors RFC-1123 `If-Modified-Since` (304). The PUSH direction has still never run: `CRM_ZOHO_SYNC` ships OFF, nothing has ever written the live Zoho tenant, and enabling the flag or hand-running a push cycle against prod stays OWNER-GATE §6.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Deployed:** migrations 144 and 145 are applied on prod as of 2026-08-06 and `/crm` is live, so live rendering, drag persistence and deep links are owner-verifiable now) · **d** integrations — **audited 2026-08-06 GO-NARROWED and the narrowed slice is BUILT** (branch `ws-26d-agent-crm`): `apps/agents/agent-crm/` (`crm-assistant`, MAF, four READ tools over the existing `/crm` routes carrying the caller's `X-User-Email`, read-only enforced at the transport by a GET-only method allowlist) registered in `_KNOWN_AGENTS` + `_AGENT_REGISTRY` + `agent_registry.json`, plus `"crm"` added to the WhatsApp `_KNOWN_SYSTEMS` allowlist **parse-only** (nothing writes `wa_contacts.entity_ref`, the `crm` context block stays `None`, both pinned by test). **The three held-back items are now DISPATCHABLE — their doc blockers (B3/B4/B5/B7) were closed 2026-08-06 in `crm_app.md` §9.1-§9.3, every anchor read off `origin/main` rather than recalled:** **WS-26d-email** (the timeline join is CALLER-scoped, never record-scoped — it reuses the email app's `_account_scope` predicate, copied into `routes/crm/` rather than imported per D-CRM-4, joins by thread not message, inbound `from_address` only, and needs a new address index at the next free migration number) · **WS-26d-autolead** (hook = `routes/email/scheduler_hooks.py::process_new_mail`, the one seam scheduler+manual+webhook all funnel through; the per-message rules loop was considered and REJECTED because a classifier outage there double-fires and history backfills never reach it; unknown-sender test mirrors `_maybe_block_cold`, colleague suppression via `is_own_mail`) · **WS-26d-write** (`request_confirmation` at the top of each tool, fail-closed, no `non_interactive_default="approve"`; `@_annotate_risk` is the shared annotation convention and is NOT enforcement; the Action Broker covers the Zoho push, not the native write — the two fail in OPPOSITE directions and are not interchangeable). The push-queue question is CLOSED — **D-CRM-9 (owner, 2026-08-06): agent-originated writes queue for Zoho exactly like human ones** (🟡 remainder; the flip stays OWNER-GATE) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. | -| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d BUILT 2026-08-06** · 🟢 e, f dispatchable · 🟡 c gated | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** personal-task connection — `pm_tasks` mirrored into `gtd_items` via an internal `commandcenter` provider, GTD overlay contract intact (🟢) · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end). | +| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — **BUILT 2026-08-06** (`routes/projects/automation.py` + `agent_dispatch.py`, the `pm_task` node type in `workflows/engine/`, `PM_EVENT_TOPICS` served by the catalog; 34 hermetic cases, 10 mutants red). Both halves of `workflows_app.md` §13 — **U1** the task-mutation node and **U7** dispatch. The engine imports a transport-free SERVICE, not a route, so an automation's edit is indistinguishable in validation from a human PATCH and lands the same timeline row; **status is named, never keyed** (a graph pinned to one project's status UUID could only automate that project); a `pm_task` node is deliberately **not** write-class (the approval gate is for outward writes — now pinned by a test rather than true by accident); and "already in target state" is asserted to issue **no UPDATE at all**, because `update_row` stamps `updated_at` and a redundant write is invisible in a diff while making a task look freshly touched. Assignment dispatches from an event SINK beside the workflows dispatcher, so a broken agent cannot fail the act of assigning somebody a task, and the handoff activity is committed BEFORE the run starts. **Engine defect found and fixed:** `resolve_value` keeps an unresolvable `{{ref}}` as-is at run time by design and `{{trigger.missing}}` passes the publish gate because its *root* is legal — the literal would have reached Postgres as a would-be uuid and returned "Task not found", pointing the maker at the wrong thing · **i** attachments — **BUILT 2026-08-06** (mig `150_projects_attachments.sql`; 25 cases, 10 mutants red): one file store (`gtd_attachments` reused, upload rules imported) with a thin `pm_task_attachments` join that carries the ACCESS decision, so a file is readable by whoever can see the task rather than only its uploader; no attach-by-id endpoint exists, because naming an arbitrary attachment id would let a caller attach somebody else's private capture to their own task and read it back. **Caught before shipping:** the projects BFF proxy re-serialised every POST as JSON, so a multipart upload would have reached the gateway with NO FILE while still answering 201 · **j–r** the rest of the ClickUp-parity gap, measured against the built tree and sequenced in spec §11 (attachments, notifications/@mentions, filters+saved views, custom fields, tags, bulk edit, recurring, dependency UI, calendar, search) — all 🟢, and **n (bulk edit) gates g**: an import that cannot be re-triaged in bulk is one somebody abandons halfway, leaving two live systems, which is the state the retirement exists to end · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). | +| **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | ✅ **a + b BUILT 2026-08-06** · 🟢 c–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE).. **a BUILT 2026-08-06** (mig `148_people_key_shape.sql` + `scripts/import_hr_people.py`; 22 cases, 11 mutants red, 1 equivalent): `UNIQUE(name)` dropped, partial unique on `lower(email)`, status CHECK. **P-1 did not name its own consequence** — the HR importer upserts `ON CONFLICT (name)` and would have failed outright, so a `source_key` (`:`) carries the upsert instead, backfilled BEFORE the constraint is dropped while `name` is still distinct. **Neither new constraint may block a deploy** (main was bitten twice this month): a duplicate address is quarantined into a new `email_conflict` column with a deterministic winner rather than failing `CREATE UNIQUE INDEX`, and the status CHECK is added `NOT VALID` then validated in a guarded block, so an unanticipated legacy value leaves a NOTICE instead of stopping the deploy. ⚠️ `schema.generated.sql` NOT refreshed — needs a live DB; regenerate on the first deploy that applies 148. **b BUILT 2026-08-06** (mig `149_people.sql`, `routes/people/`, `src/app/people/`; 32 hermetic + 28 vitest cases, 11 mutants red): `/people` directory, person page with all four panels, and the People Center's "Directory & org chart" sub-app flipped live — closing WS-13's outstanding read view. **Its own feature slug**, not `feature:tasks`: a manager who needs the org chart should not be handed the personal GTD task manager to get it. The gate is new but the HR **projection is imported** from `tasks.core` and a test asserts the function's *identity*, since two answers to "may this caller see skills" are two answers waiting to drift. Three filters (the `q` skills clause, `skill`, `has_capacity`) are dropped without `admin:members:read` so search cannot become an oracle for the hidden field — and the response carries `hr_visible` so the UI says "restricted" rather than leaving a blank strip to read as "nobody filled it in". Load is **computed from open assigned tasks** and carries `unestimated`, because a bar built from the estimate sum alone shows somebody holding thirty un-estimated tasks as completely free. The work panel is scoped by the **viewer's** grants and answers `available:false` without `feature:projects`. **Registration is FIVE places, not four** — the fifth is `test_org_access_enforcement.GATED_ROUTERS`, hand-maintained, where an absent router is unchecked rather than passing; also added the named `test_projects_is_registered_on_both_sides` that WS-27a never wrote | --- @@ -379,7 +380,8 @@ taken and dated. | Multiplayer prior art (`qm`, 2026-08-01) | **`multiplayer_prior_art_qm_2026-08.md` is reference-only** — it owns no work and no status; the specs it links stay authoritative | multiplayer README §4.6/§5.1/§6.4/§6.5 · memory-clearance §3.3/§7 · agent-kinds §9 Q1 · skills_scope_out §6 · WS-10 · WS-23 | | Memory compartments + clearance (incl. `subject:`) | **`docs/multiplayer/memory-clearance.md` §7** (surface design §7.1); dispatched as **WS-10 S1** | memory_architecture §9 `3a′` (link-only since 2026-08-02) · multiplayer README §6.3/§8 Phase 3 (index only) · prior-art §QM-D1 (reference only) | | Native CRM + the Zoho retirement path | **WS-26 — `specs/crm_app.md`** (minted 2026-08-05) | `department_centers.md` Sales Center "Pipeline" app (a projection of `/crm`, flipped live by WS-26c) · WS-1 interplay **settled 2026-08-05 (D-CRM-7/D-CRM-8) — the writer now EXISTS** (branch `ws-26b-zoho-sync`): `ingestion/sources/zoho/writer.py`, the sync engine's **single, broker-gated** writer with one grep-asserted caller (`routes/crm/sync_zoho.py::execute_push`) and three registered `crm.zoho_*` handlers that auto-apply while `ACTION_BROKER_ENFORCE` is off, retired at WS-26e. WS-1's "no Zoho write path anywhere in the repo" sentence was corrected in that same change (done-when 6) — this row and the WS-1 row now agree, and neither should be re-softened · WS-2 (the Zoho-token P0's endgame is WS-26e's **revoke**) · WS-20 §11's "Odoo/Zoho-bound items" (bind to `crm` `entity_ref` per WS-26d instead — ⚠️ as of 2026-08-06 WS-26d has made `"crm"` a KNOWN system so such a ref **parses**, but there is still no linker: nothing writes `wa_contacts.entity_ref` for any system, and the drawer's `crm` block is still `None`. Whoever binds these items owes both halves) · `orchestrator/sales_views.py` + `scripts/reconciler.py` + `skills/sales\|reconciler/*` keep reading the graph mirror until WS-26e repoints them | -| Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts — multi-branch switch, step snapshots, dependency map — are recorded there as backlog, not here) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries | +| Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts are recorded there as backlog, not here — **written up in full 2026-08-06 as `workflows_app.md` §13, items U1–U8**, where **U1** = the `pm.update_task` node and **U7** = agent dispatch, i.e. WS-27f's two halves, and U2–U6/U8 are engine work WS-27 does not wait on; §13 is backlog and changes neither Slice 3 nor Slice 4) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries | +| The People Center's surfaces (directory, org chart, capability search, seats) | **WS-28 — `specs/people_center_app.md`** (minted 2026-08-06) | It owns **surfaces, not facts**: `task_manager_hr_planning_and_memory.md` owns the HR data and the capability vectors · `org_access_control.md` owns identity, roles and overrides · `colleague_onboarding.md` owns the invite process and the role × app matrix · `department_centers.md` owns Centers and groups · `project_management_app.md` owns the work. WS-13's *People directory read view* is closed by WS-28b rather than staying open in Centers B | | Tenancy boundary + visibility model (who can see what) | **`specs/tenancy_and_visibility.md`** (D11 §1 · D12 §3–§4 · the app-by-app gap table §5 · TV-1 §2) | `department_centers.md` (the "separate deployment is for a separate org, never a department" rule) · `org_access_control.md` §8 Ph2 · `multi_user_organization_research.md` §5/§7/§8/§9/§17 (**research only, and superseded for planning by the new spec**) · `groups_sessions_authority.md` §3 (the intersection rule it constrains) · D9 (the twelve "second tenant deployment" sites) | ## 5. Documentation remediation backlog (WS-0) diff --git a/apps/services/gateway/gateway/main.py b/apps/services/gateway/gateway/main.py index 375d026e2..483decb29 100644 --- a/apps/services/gateway/gateway/main.py +++ b/apps/services/gateway/gateway/main.py @@ -1002,6 +1002,16 @@ async def relayed_generator(): except Exception: # pragma: no cover pass +try: + # WS-28 — the People Center's directory (spec: + # ai-company-brain/specs/people_center_app.md). Its own feature gate, but + # the HR projection is imported from routes/tasks, never re-implemented. + from gateway.routes.people import router as _people_router + + app.include_router(_people_router) +except Exception: # pragma: no cover + pass + try: from gateway.routes.settings import router as _settings_router @@ -1123,6 +1133,13 @@ async def relayed_generator(): from ingestion.event_hooks import register_event_sink register_event_sink(_wf_dispatch) + # WS-27f: assignment IS dispatch. A SECOND sink beside the workflows + # dispatcher rather than a call inside `PUT /tasks/{id}/assignees` — + # a slow or broken agent must not be able to fail the act of assigning + # somebody a task. + from gateway.routes.projects.agent_dispatch import on_event as _pm_agent_dispatch + + register_event_sink(_pm_agent_dispatch) except Exception: # pragma: no cover - ingestion optional in some deploys pass except Exception: # pragma: no cover diff --git a/apps/services/gateway/gateway/routes/people/__init__.py b/apps/services/gateway/gateway/routes/people/__init__.py new file mode 100644 index 000000000..afe4e0417 --- /dev/null +++ b/apps/services/gateway/gateway/routes/people/__init__.py @@ -0,0 +1,23 @@ +"""People Center · the directory as its own app. + +Spec: ``ai-company-brain/specs/people_center_app.md`` §3, §6 · ticket WS-28b. + + GET /people → the directory, searchable and filterable + GET /people/{id} → one person, with the login badge + GET /people/{id}/work → their open tasks, scoped by the VIEWER + +**Why this exists beside ``/tasks/people``.** The read API already shipped +(WS-24 N4) with the HR projection this app needs — but it is gated on +``feature:tasks``, and the People Center is a different audience: a manager who +needs the org chart and the assignee picker should not have to be handed the +personal GTD task manager to get them. So the *gate* is new and the +*projection* is imported, never re-implemented (§6: "a restriction that already +exists and must not be re-implemented here"). +""" + +# Imported for the side effect that matters: the module's decorators are what +# attach the routes to `router`. Nothing here reads a name from it. +from gateway.routes.people import directory as _directory # noqa: F401 +from gateway.routes.people.core import router + +__all__ = ["router"] diff --git a/apps/services/gateway/gateway/routes/people/core.py b/apps/services/gateway/gateway/routes/people/core.py new file mode 100644 index 000000000..64ccf204b --- /dev/null +++ b/apps/services/gateway/gateway/routes/people/core.py @@ -0,0 +1,58 @@ +"""People Center · the router, the gate, and the seams it borrows. + +Spec: ``ai-company-brain/specs/people_center_app.md`` §3, §6 · ticket WS-28b. + +**Two permissions, and they answer different questions.** +``feature:people`` decides whether the directory is reachable at all; +``admin:members:read`` decides whether the HR-sensitive half of a person record +comes back filled in or nulled. The second one is **not defined here** — it is +imported from ``routes/tasks/core`` where WS-24 N4 put it. A second definition +of "may this caller see skills" is a second answer waiting to drift from the +first, and the projection is a boundary. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import require_feature_router +from acb_common import get_logger +from fastapi import APIRouter +from gateway.routes.tasks.core import can_read_hr_fields # noqa: F401 — re-export +from sqlalchemy import text + +_log = get_logger("gateway.people") + +router = APIRouter( + prefix="/people", tags=["people"], + dependencies=[require_feature_router("people")], +) + + +async def _get_db() -> Any: + """The shared engine seam (BO-10) — never ``create_async_engine`` here.""" + from gateway.db import get_session + + return await get_session() + + +#: Statuses the directory understands, mirrored from migration 148's CHECK. +#: Listed so a bad filter value is an empty result rather than an error, and so +#: the UI can render the pill set without a second round trip. +STATUSES: tuple[str, ...] = ("active", "contractor", "alumni", "invited") + + +async def has_login(db: Any, email: str | None) -> bool: + """Does this person have an ``app_user`` row — i.e. can they sign in? + + The visible half of §2's two-store split. Case-insensitive on both sides + (R10), and a person with no address is directory-only by construction + rather than by lookup. + """ + if not (email or "").strip(): + return False + row = (await db.execute( + text("SELECT 1 FROM app_user WHERE lower(email) = :email LIMIT 1"), + {"email": email.strip().lower()}, + )).fetchone() + return row is not None diff --git a/apps/services/gateway/gateway/routes/people/directory.py b/apps/services/gateway/gateway/routes/people/directory.py new file mode 100644 index 000000000..834cb10b7 --- /dev/null +++ b/apps/services/gateway/gateway/routes/people/directory.py @@ -0,0 +1,341 @@ +"""People Center · the directory and the person page (WS-28b). + +Spec: ``ai-company-brain/specs/people_center_app.md`` §3.1, §3.2, §5.2. + +Three reads. Every one of them answers the same two questions in the same +order — *may this caller reach the surface at all* (``feature:people``, on the +router) and *may they see the HR half* (``admin:members:read``, projected) — +and neither answer is computed here. Both are imported. +""" + +from __future__ import annotations + +from typing import Any + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException +from gateway.routes.people.core import ( + STATUSES, + _get_db, + can_read_hr_fields, + has_login, + router, +) +from gateway.routes.tasks.people import _row_to_person +from pydantic import BaseModel +from sqlalchemy import text + +#: A working week, used to turn estimate minutes into the load bar's hours. +#: A constant rather than a setting: the bar is a rough signal, and a tunable +#: nobody tunes is a config surface pretending to be a feature. +MINUTES_PER_HOUR = 60 + + +class DirectoryResponse(BaseModel): + rows: list[dict[str, Any]] + total: int + #: False when the HR half came back nulled. The UI needs this to say + #: "restricted" rather than "none" — a blank skills strip meaning "you may + #: not see this" and one meaning "nobody filled it in" are different facts, + #: and a UI that cannot tell them apart will pick the wrong one (§3.1). + hr_visible: bool + + +class WorkResponse(BaseModel): + rows: list[dict[str, Any]] + total: int + #: False when the caller does not hold `feature:projects`. Same distinction + #: as `hr_visible`: "this surface is not yours" is not "they have no work". + available: bool + + +def build_directory_filters( + *, + hr: bool, + q: str = "", + department: str | None = None, + team: str | None = None, + status: str | None = None, + skill: str | None = None, + has_capacity: bool = False, +) -> tuple[list[str], dict[str, Any]]: + """The directory's WHERE clauses and binds — pure, so the rules are testable. + + Extracted from the route deliberately: the interesting decisions here are + all *whether a clause is included at all*, and that is a question about + permissions, not about rows. Proving it through a database fake would prove + it about the fake. + + Three of them are the same rule wearing different hats — **an HR-derived + filter is dropped entirely without `admin:members:read`**: + + - the ``q`` skills clause, because matching on a column that is then + stripped from the response turns the search box into an oracle for the + very field the projection exists to hide; + - ``skill``, for the same reason, more directly; + - ``has_capacity``, which is derived from the restricted capacity trio. + + Dropping them silently would be wrong on its own, which is why the response + carries ``hr_visible``: the UI must be able to say the filter did not apply + rather than present an unfiltered list as a filtered one. + """ + clauses: list[str] = ["true"] + params: dict[str, Any] = {} + if (q or "").strip(): + match = "(name ILIKE :q OR role ILIKE :q OR title ILIKE :q OR department ILIKE :q" + if hr: + match += " OR EXISTS (SELECT 1 FROM unnest(skills) s WHERE s ILIKE :q)" + clauses.append(match + ")") + params["q"] = f"%{q.strip()}%" + if department: + clauses.append("lower(department) = :department") + params["department"] = department.strip().lower() + if team: + clauses.append("lower(team) = :team") + params["team"] = team.strip().lower() + if status: + clauses.append("status = :status") + params["status"] = status + if hr and skill: + clauses.append("EXISTS (SELECT 1 FROM unnest(skills) s WHERE s ILIKE :skill)") + params["skill"] = f"%{skill.strip()}%" + if hr and has_capacity: + clauses.append("COALESCE(available_hours_per_week, 0) > 0") + return clauses, params + + +@router.get("/", response_model=DirectoryResponse) +async def list_directory( + user: UserContext = Depends(get_current_user), + q: str = "", + department: str | None = None, + team: str | None = None, + status: str | None = None, + skill: str | None = None, + has_capacity: bool = False, +) -> DirectoryResponse: + """The directory (§3.1). + + ``q`` matches name, title/role and department for everyone — and skills + **only** for a caller who may see skills. Matching on a column that is then + stripped from the response turns the search box into an oracle for the very + field the projection exists to hide; ``/tasks/people`` already made that + call and this repeats it deliberately rather than diverging. + + ``skill`` and ``has_capacity`` are HR-derived filters, so they are ignored + outright without ``admin:members:read`` — for the same reason, and stated + in the response via ``hr_visible`` so the UI does not silently show an + unfiltered list as though the filter had applied. + """ + hr = can_read_hr_fields(user) + if status is not None and status not in STATUSES: + raise HTTPException( + status_code=422, detail=f"Unknown status. One of: {list(STATUSES)}.", + ) + clauses, params = build_directory_filters( + hr=hr, q=q, department=department, team=team, status=status, + skill=skill, has_capacity=has_capacity, + ) + + db = await _get_db() + try: + rows = (await db.execute( + text( + "SELECT * FROM gtd_people WHERE " + " AND ".join(clauses) + + " ORDER BY department NULLS LAST, name" + ), + params, + )).fetchall() + people = [_row_to_person(r, include_hr=hr).model_dump() for r in rows] + finally: + await db.close() + return DirectoryResponse(rows=people, total=len(people), hr_visible=hr) + + +@router.get("/facets") +async def list_facets(user: UserContext = Depends(get_current_user)) -> dict: + """The filter vocabulary, derived from the rows rather than configured. + + A department list somebody has to maintain is a department list that goes + stale the first time the org changes — the same call ``/projects/my/contexts`` + made for GTD contexts. + """ + db = await _get_db() + try: + rows = (await db.execute( + text( + "SELECT department, team, count(*) AS total FROM gtd_people " + "WHERE department IS NOT NULL GROUP BY department, team " + "ORDER BY department, team" + ), + )).fetchall() + finally: + await db.close() + departments: dict[str, int] = {} + teams: list[dict[str, Any]] = [] + for row in rows: + departments[row.department] = departments.get(row.department, 0) + int(row.total) + if row.team: + teams.append({"department": row.department, "team": row.team, + "total": int(row.total)}) + return { + "departments": [{"department": d, "total": t} for d, t in departments.items()], + "teams": teams, + "statuses": list(STATUSES), + } + + +@router.get("/{person_id}") +async def get_person( + person_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """One person (§3.2 panels 1-3). + + 404 for a person who does not exist. There is deliberately no 403 anywhere + in this app: the directory is open to feature holders, and what varies is + the *shape* of the answer, not whether one comes back. + """ + hr = can_read_hr_fields(user) + db = await _get_db() + try: + row = (await db.execute( + text("SELECT * FROM gtd_people WHERE id = CAST(:id AS uuid)"), + {"id": person_id}, + )).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="No such person") + person = _row_to_person(row, include_hr=hr).model_dump() + person["has_login"] = await has_login(db, getattr(row, "email", None)) + # An address moved aside by migration 148 because another row claimed + # it. Surfaced rather than hidden: it means a human still has to decide + # which row is the real person. + person["email_conflict"] = getattr(row, "email_conflict", None) + person["manager"] = await _manager_name(db, getattr(row, "manager_id", None)) + person["load"] = ( + await compute_load(db, getattr(row, "email", None)) if hr else None + ) + finally: + await db.close() + person["hr_visible"] = hr + return person + + +async def _manager_name(db: Any, manager_id: Any) -> str | None: + if not manager_id: + return None + row = (await db.execute( + text("SELECT name FROM gtd_people WHERE id = CAST(:id AS uuid)"), + {"id": str(manager_id)}, + )).fetchone() + return str(row.name) if row is not None else None + + +async def compute_load(db: Any, email: str | None) -> dict[str, Any]: + """Load from OPEN ASSIGNED TASKS, not from the typed column (§5.2). + + ``gtd_people.current_load_hours_per_week`` is a number somebody typed once; + it is stale the moment anyone assigns anything. This counts what the person + is actually holding. + + The honest part is ``unestimated``: a task with no estimate contributes + nothing to the hours, so a bar built only from the sum would show somebody + with thirty un-estimated tasks as completely free. The count travels with + the number so the UI can say "plus 30 with no estimate" instead of lying. + """ + if not (email or "").strip(): + return {"open_tasks": 0, "estimated_hours": 0.0, "unestimated": 0} + row = (await db.execute( + text( + """SELECT count(*) AS open_tasks, + COALESCE(sum(t.estimate_mins), 0) AS mins, + count(*) FILTER (WHERE t.estimate_mins IS NULL) + AS unestimated + FROM pm_tasks t + JOIN pm_task_statuses s ON s.id = t.status_id + JOIN pm_task_assignees a ON a.task_id = t.id + WHERE lower(a.assignee) = :who + AND t.archived_at IS NULL + AND s.category NOT IN ('done', 'cancelled')""" + ), + {"who": email.strip().lower()}, + )).fetchone() + mins = int(getattr(row, "mins", 0) or 0) + return { + "open_tasks": int(getattr(row, "open_tasks", 0) or 0), + "estimated_hours": round(mins / MINUTES_PER_HOUR, 1), + "unestimated": int(getattr(row, "unestimated", 0) or 0), + } + + +@router.get("/{person_id}/work", response_model=WorkResponse) +async def get_person_work( + person_id: str, user: UserContext = Depends(get_current_user), +) -> WorkResponse: + """This person's open tasks, scoped by the **viewer's** grants (§3.2 panel 4). + + Scoped by the viewer and not the subject, deliberately: a Sales lead looking + at an Operations colleague should see the work they share, not that + person's whole life. The Projects grant model already answers exactly that + question, so it is imported rather than approximated. + + A caller without ``feature:projects`` gets ``available: false`` and an empty + list rather than a bare empty list — "this surface is not yours" and "they + have nothing open" must not render identically. + """ + from gateway.routes.projects.core import resolve_visibility + + if not user.has_permission("feature:projects"): + return WorkResponse(rows=[], total=0, available=False) + + db = await _get_db() + try: + person = (await db.execute( + text("SELECT email FROM gtd_people WHERE id = CAST(:id AS uuid)"), + {"id": person_id}, + )).fetchone() + if person is None: + raise HTTPException(status_code=404, detail="No such person") + email = str(getattr(person, "email", "") or "").strip().lower() + if not email: + # Directory-only with no address: assignment targets are addresses, + # so there is nothing to look up rather than nothing assigned. + return WorkResponse(rows=[], total=0, available=True) + + vis = await resolve_visibility(db, user) + params: dict[str, Any] = {"who": email} + clauses = [ + "t.archived_at IS NULL", + "s.category NOT IN ('done', 'cancelled')", + "EXISTS (SELECT 1 FROM pm_task_assignees a " + "WHERE a.task_id = t.id AND lower(a.assignee) = :who)", + ] + if not vis.unrestricted: + clauses.append(vis.project_clause("t.root_project_id")) + params.update(vis.params) + rows = (await db.execute( + text( + "SELECT t.id, t.title, t.task_number, t.due_at, t.project_id, " + " s.name AS status_name, s.category AS status_category, " + " p.name AS project_name " + " FROM pm_tasks t " + " JOIN pm_task_statuses s ON s.id = t.status_id " + " LEFT JOIN pm_projects p ON p.id = t.project_id " + " WHERE " + " AND ".join(clauses) + + " ORDER BY t.due_at NULLS LAST, t.created_at" + ), + params, + )).fetchall() + finally: + await db.close() + + items = [ + { + "id": str(r.id), "title": r.title, "task_number": r.task_number, + "due_at": r.due_at.isoformat() if getattr(r, "due_at", None) else None, + "project_id": str(r.project_id) if r.project_id else None, + "project_name": getattr(r, "project_name", None), + "status_name": r.status_name, "status_category": r.status_category, + } + for r in rows + ] + return WorkResponse(rows=items, total=len(items), available=True) diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py index 2cc2cc25b..606b9d579 100644 --- a/apps/services/gateway/gateway/routes/projects/__init__.py +++ b/apps/services/gateway/gateway/routes/projects/__init__.py @@ -23,8 +23,10 @@ from gateway.routes.projects import activities as _activities # noqa: F401 from gateway.routes.projects import admin as _admin # noqa: F401 +from gateway.routes.projects import attachments as _attachments # noqa: F401 from gateway.routes.projects import import_clickup as _import_clickup # noqa: F401 from gateway.routes.projects import me as _me # noqa: F401 +from gateway.routes.projects import personal as _personal # noqa: F401 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 diff --git a/apps/services/gateway/gateway/routes/projects/agent_dispatch.py b/apps/services/gateway/gateway/routes/projects/agent_dispatch.py new file mode 100644 index 000000000..52480c0ef --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/agent_dispatch.py @@ -0,0 +1,168 @@ +"""Projects · assignment IS dispatch — WS-27f / `workflows_app.md` §13 U7. + +Spec: `ai-company-brain/specs/project_management_app.md` §6.4. + +Assigning a task to `agent:` starts an agent run. Not a separate +"delegate to AI" button, not a parallel feature with its own field — the same +gesture that hands work to a colleague, because D-PM-4 put both species in one +assignee vocabulary and this is where that stops being a schema note. + +**Event-driven, never called from the handler.** `PUT /tasks/{id}/assignees` +emits `pm.task.assigned` and returns; this module is a *sink* on that event, +registered beside the workflows dispatcher at startup. That is Paca's shape +(research §5): the HTTP handler never calls the agent runtime, so a slow or +broken agent cannot make assigning somebody a task fail. + +**The activity lands first.** Before the agent produces a single token, an +`agent_run` row is on the task's timeline — Paca's `agent.session.started` +move. A handoff that is invisible until the agent finishes looks, for its whole +duration, exactly like a handoff that never happened. + +**Only NEW assignees dispatch.** `set_assignees` emits the *added* set, not the +whole set, so re-asserting an existing assignee cannot start a second run. That +property lives in the emitter and is relied on here; both sides say so. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from acb_common import get_logger +from gateway.routes.projects.core import _get_db, record_activity +from sqlalchemy import text + +_log = get_logger("projects.agent_dispatch") + +#: The prefix that makes an assignee an agent rather than a person (D-PM-4). +AGENT_PREFIX = "agent:" + +#: How long a dispatched run may take before the activity is marked failed. +#: Matches the workflows engine's agent-node budget — the same runtime, so a +#: different number here would only mean one of the two is lying. +AGENT_RUN_TIMEOUT_SECONDS = 600.0 + + +def agent_targets(assignees: Any) -> list[str]: + """The agent names in an assignee list, in order, deduplicated. + + Case-insensitive on the prefix because the API lowercases on write but an + event payload is not a database row and should not be trusted to have been + through it. + """ + out: list[str] = [] + seen: set[str] = set() + for raw in assignees if isinstance(assignees, list) else []: + value = str(raw or "").strip().lower() + if not value.startswith(AGENT_PREFIX): + continue + name = value[len(AGENT_PREFIX):].strip() + if name and name not in seen: + seen.add(name) + out.append(name) + return out + + +def build_message(task: Any) -> str: + """What the agent is told. The task, not a prompt template. + + Deliberately plain: the agent gets the human-readable identifier it can + quote back, the title, and the description if there is one. Anything richer + belongs in the agent's own instructions, which are code-authored in Git — + inventing a prompt here would put agent behaviour in a route package. + """ + number = getattr(task, "task_number", None) + label = f"#{number} " if number else "" + parts = [f"You have been assigned task {label}{task.title}."] + description = str(getattr(task, "description", "") or "").strip() + if description: + parts.append(description) + parts.append(f"The task id is {task.id}.") + return "\n\n".join(parts) + + +async def on_event(source: str, event_type: str, payload: dict[str, Any]) -> None: + """Event sink: start a run for every agent newly assigned to a task. + + Registered alongside the workflows dispatcher, so `pm.task.assigned` fans + out to both. Best-effort like every sink — `emit_event` swallows sink + errors by default and that default is load-bearing (a webhook must never + 5xx because a sink failed), so this returns rather than raises. + """ + if source != "projects" or event_type != "pm.task.assigned": + return + agents = agent_targets(payload.get("assignees")) + if not agents: + return + + task_id = str(payload.get("task_id") or "").strip() + if not task_id: + return + + db = await _get_db() + try: + task = (await db.execute( + text("SELECT * FROM pm_tasks WHERE id = CAST(:tid AS uuid)"), + {"tid": task_id}, + )).fetchone() + if task is None: + return + message = build_message(task) + for name in agents: + # The timeline entry is written and COMMITTED before the run + # starts, so the handoff is visible immediately rather than when + # the agent finishes. + await record_activity( + db, activity_type="agent_run", created_by=f"{AGENT_PREFIX}{name}", + task_id=task_id, body=f"Assigned to {name}; starting a run.", + meta={"agent": name, "state": "started"}, + ) + await db.commit() + finally: + await db.close() + + for name in agents: + await _run_and_record(name, message, task_id) + + +async def _run_and_record(agent: str, message: str, task_id: str) -> None: + """Run one agent and close its timeline entry either way. + + A dispatch that fails silently is worse than one that never started: the + task shows a session that appears to still be running and nobody knows to + pick the work back up. So the failure path writes too. + """ + try: + from orchestrator.executor import run_agent + except Exception as exc: # pragma: no cover — orchestrator is a hard dep + await _record_outcome(task_id, agent, ok=False, detail="orchestrator unavailable") + _log.warning("projects.agent_dispatch_unavailable", error=str(exc)) + return + + try: + result = await asyncio.wait_for( + run_agent(agent, message), timeout=AGENT_RUN_TIMEOUT_SECONDS, + ) + except TimeoutError: + await _record_outcome(task_id, agent, ok=False, detail="timed out") + return + except Exception as exc: + await _record_outcome(task_id, agent, ok=False, detail=str(exc)[:300]) + return + await _record_outcome(task_id, agent, ok=True, detail=str(result or "")[:2000]) + + +async def _record_outcome(task_id: str, agent: str, *, ok: bool, detail: str) -> None: + db = await _get_db() + try: + await record_activity( + db, activity_type="agent_run", created_by=f"{AGENT_PREFIX}{agent}", + task_id=task_id, + body=detail if ok else f"Agent run failed: {detail}", + meta={"agent": agent, "state": "finished" if ok else "failed"}, + ) + await db.commit() + except Exception as exc: # pragma: no cover — the outcome write is best-effort + _log.warning("projects.agent_dispatch_record_failed", error=str(exc)) + finally: + await db.close() diff --git a/apps/services/gateway/gateway/routes/projects/attachments.py b/apps/services/gateway/gateway/routes/projects/attachments.py new file mode 100644 index 000000000..34474a036 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/attachments.py @@ -0,0 +1,264 @@ +"""Projects · files on a task — WS-27i (spec §11.2 item 1). + + POST /projects/tasks/{task_id}/attachments multipart → descriptor + GET /projects/tasks/{task_id}/attachments → what is attached + GET /projects/attachments/{id}/{filename} → the bytes + DELETE /projects/tasks/{task_id}/attachments/{id} → detach + +**One file store.** The bytes and their metadata go into the existing +``gtd_attachments`` registry through the same validation the personal capture +flow uses — imported, not copied, so "is this extension allowed" and "how big +is too big" have one answer. ``pm_task_attachments`` is a thin join. + +**The access model is the point, and it is deliberately not the file's.** +``gtd_attachments`` is owner-scoped: ``/tasks/attachments/{id}/{name}`` serves +only to the uploader. Correct for a private capture, useless for a shared task. +Here the **join** carries the decision — a file is readable by anyone who can +see a task it is attached to — and the personal route is untouched. + +Two consequences worth stating, because both are security properties rather +than conveniences: + +1. **There is no attach-by-id endpoint.** Upload and attach are one call. If a + caller could name an arbitrary ``attachment_id``, they could attach somebody + else's private capture to a task they own and then read it through the route + below — a privilege escalation dressed as a feature. The only way a row + enters this table is by uploading the bytes in the same request. +2. **A personal capture stays unreachable here.** It has no join row, so the + serve route below cannot find it no matter who asks. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from acb_auth import UserContext, get_current_user +from fastapi import Depends, HTTPException, UploadFile +from fastapi.responses import FileResponse +from gateway.routes.projects.core import ( + ListResponse, + _get_db, + actor, + emit, + load_visible_task, + record_activity, + resolve_visibility, + router, +) + +# Imported, never re-implemented: one answer to "what may be uploaded". +from gateway.routes.tasks.attachments import ( + _BLOCKED_EXT, + _IMAGE_MIMES, + _MAX_BYTES, + _safe_name, + _storage_dir, +) +from sqlalchemy import text + + +def descriptor(row: Any) -> dict[str, Any]: + """The shape the UI renders — the same field names the capture flow uses.""" + mime = getattr(row, "mime", None) or "application/octet-stream" + name = getattr(row, "name", "attachment") + return { + "attachment_id": str(row.id), + "kind": "image" if mime in _IMAGE_MIMES else "file", + "name": name, + "mime": mime, + "size": int(getattr(row, "size_bytes", 0) or 0), + "added_by": getattr(row, "added_by", None), + "created_at": ( + row.created_at.isoformat() if getattr(row, "created_at", None) else None + ), + "url": f"/api/projects/attachments/{row.id}/{name}", + } + + +@router.post("/tasks/{task_id}/attachments", status_code=201) +async def attach_file( + task_id: str, + file: UploadFile, + user: UserContext = Depends(get_current_user), +) -> dict[str, Any]: + """Upload one file onto a task. + + Visibility is checked FIRST — before the bytes are read, let alone written. + Validating a 15MB upload and then discovering the caller cannot see the task + would mean an unauthorised caller could still make the server do the work. + """ + email = actor(user) + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + + name = _safe_name(file.filename or "attachment") + if Path(name).suffix.lower() in _BLOCKED_EXT: + raise HTTPException( + status_code=400, detail=f"File type not allowed: {name}", + ) + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Empty file") + if len(content) > _MAX_BYTES: + raise HTTPException( + status_code=413, + detail=f"Attachment too large ({len(content)} bytes; " + f"max {_MAX_BYTES}).", + ) + + att_id = str(uuid4()) + mime = file.content_type or "application/octet-stream" + dest = _storage_dir() / f"{att_id}{Path(name).suffix.lower()}" + dest.write_bytes(content) + + await db.execute( + text( + "INSERT INTO gtd_attachments " + "(id, user_id, name, mime, size_bytes, path) " + "VALUES (CAST(:id AS uuid), :uid, :name, :mime, :size, :path)" + ), + {"id": att_id, "uid": email, "name": name, "mime": mime, + "size": len(content), "path": str(dest)}, + ) + await db.execute( + text( + "INSERT INTO pm_task_attachments (task_id, attachment_id, added_by) " + "VALUES (CAST(:tid AS uuid), CAST(:aid AS uuid), :who) " + "ON CONFLICT (task_id, attachment_id) DO NOTHING" + ), + {"tid": task_id, "aid": att_id, "who": email}, + ) + await record_activity( + db, activity_type="attachment", created_by=email, task_id=task_id, + body=f"Attached {name}", + meta={"attachment_id": att_id, "name": name, "mime": mime, + "size": len(content)}, + ) + await db.commit() + result = { + "attachment_id": att_id, "name": name, "mime": mime, + "size": len(content), "added_by": email, + "kind": "image" if mime in _IMAGE_MIMES else "file", + "url": f"/api/projects/attachments/{att_id}/{name}", + } + project_id = str(task.project_id) + finally: + await db.close() + + await emit("pm.task.updated", {"task_id": task_id, "project_id": project_id, + "attachment_added": att_id}) + return result + + +@router.get("/tasks/{task_id}/attachments") +async def list_attachments( + task_id: str, user: UserContext = Depends(get_current_user), +) -> ListResponse: + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + rows = (await db.execute( + text( + "SELECT a.id, a.name, a.mime, a.size_bytes, " + " ta.added_by, ta.created_at " + " FROM pm_task_attachments ta " + " JOIN gtd_attachments a ON a.id = ta.attachment_id " + " WHERE ta.task_id = CAST(:tid AS uuid) " + " ORDER BY ta.created_at" + ), + {"tid": task_id}, + )).fetchall() + finally: + await db.close() + items = [descriptor(r) for r in rows] + return ListResponse(rows=items, total=len(items)) + + +@router.get("/attachments/{attachment_id}/{filename}") +async def serve_attachment( + attachment_id: str, + filename: str, # cosmetic — the stored name wins, as in the personal route + user: UserContext = Depends(get_current_user), +) -> FileResponse: + """Serve the bytes if the caller can see a task this file is attached to. + + Addressed by attachment rather than by task on purpose: a file may hang off + more than one task, and requiring the caller to name the *right* one would + make a legitimate read fail depending on which task they came from. + + 404 for "not attached to anything you can see" as well as "no such file" + (R5). A 403 here would confirm the file exists. + """ + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + params: dict[str, Any] = {"aid": attachment_id} + clauses = ["ta.attachment_id = CAST(:aid AS uuid)"] + if not vis.unrestricted: + clauses.append(vis.project_clause("t.root_project_id")) + params.update(vis.params) + row = (await db.execute( + text( + "SELECT a.name, a.mime, a.path " + " FROM pm_task_attachments ta " + " JOIN pm_tasks t ON t.id = ta.task_id " + " JOIN gtd_attachments a ON a.id = ta.attachment_id " + " WHERE " + " AND ".join(clauses) + " LIMIT 1" + ), + params, + )).fetchone() + finally: + await db.close() + if row is None or not Path(row.path).is_file(): + raise HTTPException(status_code=404, detail="Attachment not found") + return FileResponse( + row.path, media_type=row.mime or "application/octet-stream", + filename=row.name, + ) + + +@router.delete("/tasks/{task_id}/attachments/{attachment_id}") +async def detach_file( + task_id: str, attachment_id: str, + user: UserContext = Depends(get_current_user), +) -> dict[str, Any]: + """Detach a file from a task. + + **The bytes are kept.** Detaching is a statement about this task, and the + same file may hang off another one; deleting the row from under it would + turn one person's tidy-up into somebody else's broken link. A file store + sweep is a separate, deliberate job. + + Detaching something already gone is a no-op, not a 404 — Paca's "lenient + removes" lesson (research §6), which is what makes a retry after a + half-failed request safe. + """ + email = actor(user) + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + result = await db.execute( + text( + "DELETE FROM pm_task_attachments " + " WHERE task_id = CAST(:tid AS uuid) " + " AND attachment_id = CAST(:aid AS uuid)" + ), + {"tid": task_id, "aid": attachment_id}, + ) + removed = int(getattr(result, "rowcount", 0) or 0) + if removed: + await record_activity( + db, activity_type="attachment", created_by=email, + task_id=task_id, body="Removed an attachment", + meta={"attachment_id": attachment_id, "removed": True}, + ) + await db.commit() + finally: + await db.close() + return {"task_id": task_id, "attachment_id": attachment_id, "removed": removed} diff --git a/apps/services/gateway/gateway/routes/projects/automation.py b/apps/services/gateway/gateway/routes/projects/automation.py new file mode 100644 index 000000000..6120d11b1 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/automation.py @@ -0,0 +1,193 @@ +"""Projects · the seam automation writes through — WS-27f / `workflows_app.md` §13 U1. + +**Transport-free on purpose.** The `/workflows` engine imports +:func:`apply_task_patch` and nothing else from this app; there is no HTTP hop, +no second validation path, and no route here. That is Paca's discipline stated +as code (research §4): an automation must *mutate through the ordinary task +service* so its edit gets identical validation and an identical timeline entry +— an engine that wrote `pm_tasks` directly would produce tasks whose history +does not explain how they got that way. + +Three things this owes, and each is load-bearing: + +1. **A status move is a TRANSITION**, delegated to + :func:`core.apply_status_transition`, never written as a column. That helper + owns `completed_at` and the `status_change` activity; skipping it looks + right in the UI and silently empties the timeline. +2. **"Already in target state" is a no-op**, not a write (research §9). It is + what makes a crashed automation walk safe to retry, and it is why the run + step records `skipped` rather than inventing a field change that did not + happen. +3. **Status is named, never keyed.** A workflow says `"Done"`, not a UUID. + Statuses are per-project rows, so a graph pinned to one project's status id + is a graph that can only ever automate that project — the opposite of what + an automation is for. + +**Who this acts as.** `system:workflow:`, inside the existing +`email | agent:` actor vocabulary (D-PM-4), and **not** member-scoped: +there is deliberately no visibility check here. A published workflow is an +org-level artifact — publishing requires the `workflows:publish` capability — +so its writes are the platform's, not any one member's. Scoping them to +whoever happened to trigger the run would mean the same automation silently +did different things depending on who tripped it, which is worse than either +consistent answer. +""" + +from __future__ import annotations + +from typing import Any + +from gateway.routes.projects.core import ( + apply_status_transition, + coerce_write_values, + diff_changes, + record_activity, + require_row, + update_row, +) +from sqlalchemy import text + +#: The fields an automation may patch. A deliberately SHORT list: everything +#: here is a plain column on the task. Structural moves (`project_id`, +#: `parent_task_id`) are excluded because they re-stamp `root_project_id` across +#: a subtree and belong to the move endpoint, and `status_id` is excluded +#: because a status move is a transition — see the module docstring. +PATCHABLE_FIELDS: tuple[str, ...] = ( + "title", "description", "importance", "due_at", "start_date", "estimate_mins", +) + +#: Mirrors `tasks.py::_TRACKED_TASK_FIELDS` for the subset above — the same +#: fields earn the same `field_change` activity whoever wrote them. +_TRACKED: tuple[str, ...] = PATCHABLE_FIELDS + + +class TaskPatchError(Exception): + """The patch cannot be applied; the message is safe to show in a run.""" + + +def workflow_actor(workflow_id: str) -> str: + """The actor string an automation writes under. + + One vocabulary for every species that can act on a task — a person by + email, an agent as `agent:`, an automation as + `system:workflow:`. A fourth shape would fork the field that the + timeline, the activity spine and every report already read. + """ + return f"system:workflow:{workflow_id}" + + +async def resolve_status(db: Any, root_project_id: str, wanted: str) -> Any: + """Find a status in this task's project by NAME, then by category. + + Name first because that is what a maker types and sees on the board; + category as the fallback so `"done"` keeps working on a project whose lane + is called "Shipped". Both are case-insensitive. + + A miss raises with the project's actual lane names in the message. The + alternative — failing with "status not found" — sends the maker to guess at + a vocabulary that is one query away. + """ + rows = (await db.execute( + text( + "SELECT * FROM pm_task_statuses WHERE project_id = CAST(:pid AS uuid) " + "ORDER BY position" + ), + {"pid": root_project_id}, + )).fetchall() + target = (wanted or "").strip().lower() + if not target: + raise TaskPatchError("status must name a lane") + for row in rows: + if str(row.name).strip().lower() == target: + return row + for row in rows: + if str(row.category).strip().lower() == target: + return row + names = ", ".join(str(r.name) for r in rows) or "none" + raise TaskPatchError(f"no status '{wanted}' in this project (has: {names})") + + +async def apply_task_patch( + db: Any, + task_id: str, + fields: dict[str, Any], + *, + actor: str, +) -> dict[str, Any]: + """Patch a task the way a human PATCH would, and report what changed. + + ONE multi-field node, not one node per field — Paca merged five + single-field actions into `update_task` and recorded it as a consolidation + lesson (research §4). Adopting the end state directly is cheaper than + rediscovering it. + + Returns ``{task_id, changed: [field…], status: name|None, skipped: bool}``. + ``skipped`` is true when the task was already in the requested state, which + the run step records instead of a phantom edit. + """ + unknown = [ + k for k in fields + if k != "status" and k not in PATCHABLE_FIELDS + ] + if unknown: + raise TaskPatchError( + f"cannot set {sorted(unknown)} — one of " + f"{[*PATCHABLE_FIELDS, 'status']}" + ) + + task = await require_row(db, "pm_tasks", task_id, "Task") + wanted_status = fields.get("status") + values = {k: v for k, v in fields.items() if k in PATCHABLE_FIELDS} + + changed: list[str] = [] + after = task + if values: + # Compare BEFORE writing so an unchanged field is not a write. Without + # this, an automation that fires on every task update rewrites the same + # value forever and fills the timeline with edits nobody made. + pending = { + k: v for k, v in values.items() + if _differs(getattr(task, k, None), v) + } + if pending: + after = await update_row(db, "pm_tasks", task_id, coerce_write_values(pending)) + 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}, + ) + + status_name: str | None = None + if wanted_status is not None: + 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, + ) + after = moved["row"] + changed.append("status") + status_name = str(moved["to"].name) + else: + status_name = str(target.name) + + return { + "task_id": task_id, + "changed": changed, + "status": status_name, + "skipped": not changed, + } + + +def _differs(current: Any, wanted: Any) -> bool: + """Loose inequality for the pre-write comparison. + + Deliberately stringly for non-null values: the DB hands back a `datetime` + or a `Decimal` where the graph carries the ISO string or int the maker + typed, and a strict `!=` would call every one of those a change and rewrite + it on every single run. + """ + if current is None or wanted is None: + return current is not wanted + return str(current) != str(wanted) diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py index a192dbbb6..26ffbf51d 100644 --- a/apps/services/gateway/gateway/routes/projects/core.py +++ b/apps/services/gateway/gateway/routes/projects/core.py @@ -292,6 +292,10 @@ def offset(self) -> int: TIMESTAMP_COLUMNS: frozenset[str] = frozenset({ "due_at", "completed_at", "archived_at", "clickup_synced_at", + # The personal overlay's instants (147). Same rule, same reason: bare + # `text()` declares no column type, so an ISO string would arrive at a + # timestamptz as text. + "defer_until", "clarified_at", }) DATE_COLUMNS: frozenset[str] = frozenset({"start_date"}) diff --git a/apps/services/gateway/gateway/routes/projects/personal.py b/apps/services/gateway/gateway/routes/projects/personal.py new file mode 100644 index 000000000..fc0b6d340 --- /dev/null +++ b/apps/services/gateway/gateway/routes/projects/personal.py @@ -0,0 +1,571 @@ +"""Projects · the personal lens — one task store, seen as my own work. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §3.11-§3.12, §6.1 · +**D-PM-6 (revised 2026-08-06)** · ticket WS-27e. + + GET /projects/my/inbox → my work, with my overlay + GET /projects/my/project → my personal project + POST /projects/my/project → …creating it if absent + POST /projects/my/tasks → quick capture into it + PATCH /projects/tasks/{task_id}/personal → set MY overlay on a task + GET /projects/my/contexts → the contexts I actually use + +**There is no sync here, and that is the whole point.** A task assigned to a +member is not copied into their inbox — it *is* the row in their inbox. So +completing it in the personal view completes it for the project, because there +is one row and one status; and a project manager watching the board sees the +same fact at the same instant. + +What is per-member is the **overlay**: disposition, context, energy, defer. +Two people assigned the same task legitimately hold different ones — the person +doing it says NEXT, the person who delegated it says WAITING — which a single +column on ``pm_tasks`` could not express. That is not an edge case; it is what +delegation looks like. + +**Identity comes from the session, never from a parameter.** Every route here +resolves the caller and scopes to them; there is deliberately no `?member=`, +so no request can be made to read or write somebody else's practice. +""" + +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 ( + CLOSING_CATEGORIES, + ListResponse, + Page, + TaskModel, + _get_db, + actor, + clean_payload, + coerce_write_values, + emit, + insert_row, + load_default_status, + load_visible_task, + next_task_number, + now, + record_activity, + resolve_visibility, + router, + row_to_dict, +) +from pydantic import BaseModel +from sqlalchemy import text + +#: Migration 48's vocabulary, unchanged — WS-27h has to move every gtd_items row +#: onto these and a renamed disposition would make that a translation. +DISPOSITIONS: tuple[str, ...] = ( + "INBOX", "NEXT", "WAITING", "SOMEDAY", "PROJECT", "REFERENCE", "DONE", "TRASH", +) + +ENERGIES: tuple[str, ...] = ("low", "medium", "high") + +#: The name a member's personal project is created with. Not shown as a project +#: in team lists — it carries `personal_owner`, which every team read excludes. +PERSONAL_PROJECT_NAME = "My tasks" + + +class PersonalIn(BaseModel): + """The overlay a member may set on a task. All optional; `null` clears.""" + + disposition: str | None = None + next_action: str | None = None + context: str | None = None + energy: str | None = None + time_estimate_mins: int | None = None + is_two_minute: bool | None = None + defer_until: str | None = None + + +class CaptureIn(BaseModel): + title: str + next_action: str | None = None + context: str | None = None + due_at: str | None = None + + +# ── The derived disposition ───────────────────────────────────────────────── + +def derive_disposition( + *, status_category: str, is_mine: bool, has_assignee: bool, +) -> str: + """The disposition a member has NOT stated, read off the task itself. + + The same lens the ClickUp pull has always applied (``routes/tasks/sync.py``), + lifted here so both halves of the app agree about what an untriaged task + means: + + closed in the tool → DONE + backlog-ish → SOMEDAY + assigned to me → NEXT + assigned to somebody else → WAITING + unassigned → INBOX + + Derived, never written. Storing it on first read would turn "never triaged" + into "triaged to NEXT" and quietly empty the Weekly Review — the one + question that review exists to ask is which tasks the member has not looked + at, and that is exactly the rows with no stated disposition. + """ + if status_category in CLOSING_CATEGORIES: + return "DONE" + if status_category == "backlog": + return "SOMEDAY" + if is_mine: + return "NEXT" + if has_assignee: + return "WAITING" + return "INBOX" + + +# ── The personal project ──────────────────────────────────────────────────── + +async def _load_personal_project(db: Any, email: str) -> Any | None: + return (await db.execute( + text( + "SELECT * FROM pm_projects WHERE lower(personal_owner) = :who" + ), + {"who": email}, + )).fetchone() + + +async def ensure_personal_project(db: Any, email: str) -> Any: + """This member's personal project, created on first use. + + Idempotent by the partial unique index on ``lower(personal_owner)``: two + concurrent captures cannot mint two personal projects, and the loser of that + race re-reads the winner's rather than failing the capture. + + It is an ordinary project — it gets statuses, a counter and a grant like any + other. That is what makes a private todo a first-class task: the board, the + timeline, automation and agent dispatch all work on it with no special case. + """ + existing = await _load_personal_project(db, email) + if existing is not None: + return existing + + project = await insert_row(db, "pm_projects", { + "name": PERSONAL_PROJECT_NAME, + "description": "Work only you can see. Tasks assigned to you from team " + "projects appear in your inbox without living here.", + "personal_owner": email, + "created_by": email, + "source": "manual", + }) + project_id = str(project.id) + + # The grant is what the visibility model reads; `personal_owner` is only the + # fast path to finding it. Both, so neither is load-bearing alone. + await db.execute( + text( + "INSERT INTO pm_project_grants (project_id, subject, created_by) " + "VALUES (CAST(:pid AS uuid), :who, :who) " + "ON CONFLICT (project_id, subject) DO NOTHING" + ), + {"pid": project_id, "who": email}, + ) + for name, category, position, is_default in ( + ("Inbox", "backlog", 10, True), + ("Next", "todo", 20, False), + ("Doing", "in_progress", 30, False), + ("Done", "done", 40, False), + ): + await insert_row(db, "pm_task_statuses", { + "project_id": project_id, "name": name, "category": category, + "position": position, "is_default": is_default, + }) + return project + + +@router.get("/my/project") +async def get_my_project(user: UserContext = Depends(get_current_user)) -> dict: + """My personal project, or 404 if I have never captured anything.""" + email = actor(user).lower() + db = await _get_db() + try: + row = await _load_personal_project(db, email) + if row is None: + raise HTTPException(status_code=404, detail="No personal project yet") + return {"id": str(row.id), "name": row.name} + finally: + await db.close() + + +@router.post("/my/project", status_code=201) +async def create_my_project(user: UserContext = Depends(get_current_user)) -> dict: + email = actor(user).lower() + db = await _get_db() + try: + row = await ensure_personal_project(db, email) + await db.commit() + return {"id": str(row.id), "name": row.name} + finally: + await db.close() + + +@router.post("/my/tasks", status_code=201) +async def capture( + payload: CaptureIn, user: UserContext = Depends(get_current_user), +) -> dict: + """Quick capture — a thought into my personal project, assigned to me. + + GTD's first discipline is that capture must be frictionless, so this takes a + title and nothing else is required: no project to choose, no status to pick. + The task it creates is an ordinary ``pm_tasks`` row, which is what lets a + captured thought later be moved into a team project without being recreated. + """ + title = (payload.title or "").strip() + if not title: + raise HTTPException(status_code=422, detail="A task needs a title.") + + email = actor(user).lower() + db = await _get_db() + try: + project = await ensure_personal_project(db, email) + project_id = str(project.id) + status = await load_default_status(db, project_id) + task = await insert_row(db, "pm_tasks", { + "project_id": project_id, + "root_project_id": project_id, + "task_number": await next_task_number(db, project_id), + "status_id": str(status.id), + "title": title, + "due_at": payload.due_at, + "created_by": email, + "source": "manual", + }) + task_id = str(task.id) + await db.execute( + text( + "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) " + "VALUES (CAST(:tid AS uuid), :who, :who) " + "ON CONFLICT (task_id, assignee) DO NOTHING" + ), + {"tid": task_id, "who": email}, + ) + if payload.next_action or payload.context: + await _upsert_personal(db, task_id, email, { + "next_action": payload.next_action, + "context": payload.context, + }) + await record_activity( + db, activity_type="system", created_by=email, task_id=task_id, + body="Captured", + ) + await db.commit() + result = row_to_dict(task, TaskModel) + finally: + await db.close() + + await emit("pm.task.created", {"task_id": task_id, "project_id": project_id, + "title": title}) + return result + + +# ── The overlay ───────────────────────────────────────────────────────────── + +async def _upsert_personal( + db: Any, task_id: str, email: str, values: dict[str, Any], +) -> Any: + """Write MY overlay row for a task. Never anybody else's.""" + columns = ["task_id", "member_email", *values] + assignments = ", ".join(f"{c} = EXCLUDED.{c}" for c in values) + # `pm_task_personal` has a COMPOSITE key, so the shared `update_row` helper + # — which keys on `id` — cannot serve it; this upsert is written out. The + # values still go through the shared coercion so an ISO `defer_until` + # becomes a real instant. + bound = coerce_write_values(values) + return (await db.execute( + text( + f"INSERT INTO pm_task_personal ({', '.join(columns)}) " + f"VALUES (CAST(:task_id AS uuid), :member_email, " + f"{', '.join(f':{c}' for c in values)}) " + f"ON CONFLICT (task_id, member_email) DO UPDATE " + f"SET {assignments}, updated_at = now() " + f"RETURNING *" + ), + {"task_id": task_id, "member_email": email, **bound}, + )).fetchone() + + +@router.patch("/tasks/{task_id}/personal") +async def set_personal( + task_id: str, payload: PersonalIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Set my overlay on a task. + + Writes **only** to ``pm_task_personal``. It cannot touch the task's shared + columns — a member filing something as SOMEDAY must not move it on the + team's board — which is the structural half of "the overlay is never + clobbered", now true in both directions. + """ + values = clean_payload(payload) + if values.get("disposition") is not None and values["disposition"] not in DISPOSITIONS: + raise HTTPException( + status_code=422, + detail=f"Unknown disposition. One of: {list(DISPOSITIONS)}.", + ) + if values.get("energy") is not None and values["energy"] not in ENERGIES: + raise HTTPException( + status_code=422, detail=f"Unknown energy. One of: {list(ENERGIES)}.", + ) + + email = actor(user).lower() + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + # Seeing the task is the floor. Assignment already satisfies it + # (`load_visible_task`), so a task delegated across a Center boundary is + # triageable by the person asked to do it — which is the case that would + # otherwise be unusable. + await load_visible_task(db, vis, task_id) + + # Triage is recorded even when nothing changed: "when did I last look at + # this" is the Weekly Review's question, and a no-op PATCH is still a + # member looking at it. + values["clarified_at"] = now() + row = await _upsert_personal(db, task_id, email, values) + await db.commit() + return _personal_to_dict(row) + finally: + await db.close() + + +def _personal_to_dict(row: Any) -> dict[str, Any]: + return { + "task_id": str(getattr(row, "task_id", "")), + "disposition": getattr(row, "disposition", None), + "next_action": getattr(row, "next_action", None), + "context": getattr(row, "context", None), + "energy": getattr(row, "energy", None), + "time_estimate_mins": getattr(row, "time_estimate_mins", None), + "is_two_minute": bool(getattr(row, "is_two_minute", False)), + "defer_until": ( + getattr(row, "defer_until", None).isoformat() + if getattr(row, "defer_until", None) is not None + else None + ), + } + + +# ── The inbox ─────────────────────────────────────────────────────────────── + +#: My work: everything assigned to me, plus everything in my personal project. +#: +#: The second arm matters — a task I captured and then unassigned is still mine +#: to see; without it, clearing my own name off a private todo would make it +#: vanish from the only place it exists. +_MY_TASKS_SQL = """ +SELECT t.*, + s.category AS status_category, + p.disposition AS p_disposition, + p.next_action AS p_next_action, + p.context AS p_context, + p.energy AS p_energy, + p.time_estimate_mins AS p_time_estimate_mins, + p.is_two_minute AS p_is_two_minute, + p.defer_until AS p_defer_until, + (SELECT count(*) FROM pm_task_assignees a2 WHERE a2.task_id = t.id) + AS assignee_count, + EXISTS (SELECT 1 FROM pm_task_assignees a3 + WHERE a3.task_id = t.id AND lower(a3.assignee) = :who) + AS is_mine +FROM pm_tasks t +JOIN pm_task_statuses s ON s.id = t.status_id +LEFT JOIN pm_task_personal p + ON p.task_id = t.id AND lower(p.member_email) = :who +LEFT JOIN pm_projects proj ON proj.id = t.project_id +WHERE t.archived_at IS NULL + AND ( + EXISTS (SELECT 1 FROM pm_task_assignees a + WHERE a.task_id = t.id AND lower(a.assignee) = :who) + OR lower(proj.personal_owner) = :who + ) +""" + + +@router.get("/my/inbox") +async def my_inbox( + user: UserContext = Depends(get_current_user), + disposition: str | None = None, + context: str | None = None, + include_deferred: bool = False, + include_done: bool = False, + page: Page = Depends(), +) -> ListResponse: + """My work — the org's tasks and my own, as one list, with my overlay. + + **No visibility clause**, deliberately, and for the same reason + ``/assigned-to-me`` has none: assignment is itself the strongest claim to a + task, so scoping this by project grants would hide work from the person + asked to do it. + + Filtering by ``disposition`` matches the *effective* one — stated where the + member has triaged, derived otherwise — so "show me my next actions" answers + the same way whether or not they have been through the inbox. Filtering on + the stored column alone would show an empty Next list to somebody with + twenty assigned tasks. + """ + if disposition is not None and disposition not in DISPOSITIONS: + raise HTTPException( + status_code=422, + detail=f"Unknown disposition. One of: {list(DISPOSITIONS)}.", + ) + + email = actor(user).lower() + clauses: list[str] = [] + params: dict[str, Any] = {"who": email} + if not include_deferred: + # The tickler: a deferred task is not in the inbox until its date. + clauses.append("(p.defer_until IS NULL OR p.defer_until <= now())") + if context: + clauses.append("lower(p.context) = :context") + params["context"] = context.strip().lower() + + sql = _MY_TASKS_SQL + ("".join(f" AND {c}" for c in clauses)) + db = await _get_db() + try: + rows = (await db.execute(text(sql), params)).fetchall() + finally: + await db.close() + + items: list[dict[str, Any]] = [] + for row in rows: + effective = getattr(row, "p_disposition", None) or derive_disposition( + status_category=str(getattr(row, "status_category", "") or ""), + is_mine=bool(getattr(row, "is_mine", False)), + has_assignee=int(getattr(row, "assignee_count", 0) or 0) > 0, + ) + if not include_done and effective in ("DONE", "TRASH"): + continue + if disposition is not None and effective != disposition: + continue + task = row_to_dict(row, TaskModel) + task["disposition"] = effective + # Whether the member has actually triaged it — the Weekly Review reads + # this, and it is the distinction a stored default would have destroyed. + task["is_triaged"] = getattr(row, "p_disposition", None) is not None + task["next_action"] = getattr(row, "p_next_action", None) + task["context"] = getattr(row, "p_context", None) + task["energy"] = getattr(row, "p_energy", None) + task["is_two_minute"] = bool(getattr(row, "p_is_two_minute", False)) + items.append(task) + + total = len(items) + window = items[page.offset : page.offset + page.limit] + return ListResponse(rows=window, total=total) + + +@router.get("/my/contexts") +async def my_contexts(user: UserContext = Depends(get_current_user)) -> dict: + """The contexts this member actually uses, with counts. + + Derived from their own rows rather than from a configured list: a context + vocabulary somebody has to maintain is a context vocabulary that goes stale, + and GTD contexts are personal by nature. + """ + email = actor(user).lower() + db = await _get_db() + try: + rows = (await db.execute( + text( + "SELECT p.context AS context, count(*) AS total " + "FROM pm_task_personal p " + "JOIN pm_tasks t ON t.id = p.task_id " + "WHERE lower(p.member_email) = :who AND p.context IS NOT NULL " + " AND t.archived_at IS NULL " + "GROUP BY p.context ORDER BY count(*) DESC, p.context" + ), + {"who": email}, + )).fetchall() + return { + "rows": [ + {"context": r.context, "total": int(r.total)} for r in rows + ], + "total": len(rows), + } + finally: + await db.close() + + +# ── Completion, from the personal side ────────────────────────────────────── + +@router.post("/tasks/{task_id}/complete") +async def complete_task( + task_id: str, user: UserContext = Depends(get_current_user), +) -> dict: + """Tick a task off from my inbox. + + This moves the task's SHARED status into its project's done lane — it is not + a personal-only "done". That is the cohesion the one-store design buys: + finishing something in your own list finishes it for the project, at the + same instant, because there is one row. A personal-only completion would be + a member quietly marking a team task finished while the board still shows it + open, which is the exact drift a mirror produces. + """ + from gateway.routes.projects.core import apply_status_transition + + email = actor(user).lower() + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + task = await load_visible_task(db, vis, task_id) + done = (await db.execute( + text( + "SELECT * FROM pm_task_statuses " + "WHERE project_id = CAST(:root AS uuid) AND category = 'done' " + "ORDER BY position LIMIT 1" + ), + {"root": str(task.root_project_id)}, + )).fetchone() + if done is None: + raise HTTPException( + status_code=422, + detail="This project has no done status; add one first.", + ) + moved = await apply_status_transition( + db, task, str(done.id), created_by=email, + ) + # And the member's own view of it follows, so a completed task does not + # sit in their Next list contradicting the board. + await _upsert_personal(db, task_id, email, {"disposition": "DONE"}) + await db.commit() + result = row_to_dict(moved["row"], TaskModel) + finally: + await db.close() + + await emit("pm.task.status_changed", { + "task_id": task_id, "from": moved["from"].name, "to": moved["to"].name, + "to_category": moved["to"].category, + }) + return result + + +class DeferIn(BaseModel): + until: str + + +@router.post("/tasks/{task_id}/defer") +async def defer_task( + task_id: str, payload: DeferIn, + user: UserContext = Depends(get_current_user), +) -> dict: + """Hide a task from my inbox until a date. Mine only — the team's board is + unaffected, because deferring is a statement about my attention, not about + the work.""" + email = actor(user).lower() + db = await _get_db() + try: + vis = await resolve_visibility(db, user) + await load_visible_task(db, vis, task_id) + row = await _upsert_personal(db, task_id, email, { + "defer_until": payload.until, "disposition": "SOMEDAY", + }) + await db.commit() + return _personal_to_dict(row) + 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 5f7a885f4..4727a1c72 100644 --- a/apps/services/gateway/gateway/routes/projects/tree.py +++ b/apps/services/gateway/gateway/routes/projects/tree.py @@ -99,9 +99,15 @@ class DeleteResponse(BaseModel): async def _visible_projects(db: Any, user: UserContext) -> list[Any]: vis = await resolve_visibility(db, user) + # Personal projects are excluded from every TEAM read (147/§3.11). They are + # ordinary projects the grant model already scopes to one person, so this is + # not a security filter — it is that "My tasks" does not belong in a + # department tree beside Sales and Operations. The personal surface reads + # them through `/projects/my/*`. return (await db.execute( text( - f"SELECT * FROM pm_projects WHERE {vis.project_clause()} " + f"SELECT * FROM pm_projects " + f"WHERE {vis.project_clause()} AND personal_owner IS NULL " f"ORDER BY position NULLS LAST, name" ), vis.params, diff --git a/apps/services/gateway/gateway/routes/workflows/catalog.py b/apps/services/gateway/gateway/routes/workflows/catalog.py index 2eaee547a..48909da32 100644 --- a/apps/services/gateway/gateway/routes/workflows/catalog.py +++ b/apps/services/gateway/gateway/routes/workflows/catalog.py @@ -72,6 +72,16 @@ "Long waits survive restarts; the scheduler resumes them." ), }, + { + "type": "pm_task", + "category": "action", + "label": "Update task (Projects)", + "description": ( + "Set fields on a task in the Projects app — title, description, " + "importance, dates, or its status by lane name. An internal write: " + "no approval node required." + ), + }, { "type": "output", "category": "output", @@ -80,6 +90,32 @@ }, ] +#: The `pm.*` topics a workflow can bind an event trigger to (WS-27f). +#: Served rather than typed from memory — an editor offering a topic the app +#: does not emit is a trigger that silently never fires (D7, the same rule the +#: agent and integration lists already follow). +PM_EVENT_TOPICS = [ + {"source": "projects", "event_type": "pm.task.created", "label": "Task created"}, + {"source": "projects", "event_type": "pm.task.updated", "label": "Task updated"}, + { + "source": "projects", + "event_type": "pm.task.status_changed", + "label": "Task status changed", + }, + {"source": "projects", "event_type": "pm.task.assigned", "label": "Task assigned"}, + {"source": "projects", "event_type": "pm.task.moved", "label": "Task moved"}, + {"source": "projects", "event_type": "pm.task.deleted", "label": "Task deleted"}, + { + "source": "projects", + "event_type": "pm.task.comment_added", + "label": "Comment added to a task", + }, + {"source": "projects", "event_type": "pm.project.created", "label": "Project created"}, + {"source": "projects", "event_type": "pm.project.updated", "label": "Project updated"}, + {"source": "projects", "event_type": "pm.project.moved", "label": "Project moved"}, + {"source": "projects", "event_type": "pm.project.deleted", "label": "Project deleted"}, +] + CONDITION_OPS = [ "equals", "not_equals", @@ -219,6 +255,7 @@ async def get_catalog( return { "node_types": NODE_TYPE_META, "condition_ops": CONDITION_OPS, + "event_topics": PM_EVENT_TOPICS, "agents": _agent_entries(), "integrations": _integration_entries(), "tools": [_tool_entry(s) for s in list_tools()], diff --git a/apps/services/gateway/gateway/routes/workflows/engine/graph.py b/apps/services/gateway/gateway/routes/workflows/engine/graph.py index 809cd2bea..d24022723 100644 --- a/apps/services/gateway/gateway/routes/workflows/engine/graph.py +++ b/apps/services/gateway/gateway/routes/workflows/engine/graph.py @@ -43,12 +43,32 @@ "approval", "wait", "output", + # WS-27f / §13 U1 — the first node type that acts on an INTERNAL app. + # Not a `tool`: tools reach external systems through the Integration + # Registry and carry the write-class approval gate, which a task moving + # to Done must not need. + "pm_task", } ) #: Node types whose outgoing edges carry branch handles. BRANCHING_TYPES = frozenset({"condition"}) +#: What a ``pm_task`` node may set. +#: +#: **Deliberately a copy** of `routes/projects/automation.PATCHABLE_FIELDS` +#: (plus `status`), not an import: this engine is transport-free and must not +#: acquire an import edge into an app package to validate a graph. The house +#: answer to a copied vocabulary is a both-ways fence, and there is one — +#: `test_projects_automation.py` fails if either side grows a field the other +#: lacks, in either direction. +PM_TASK_FIELDS = frozenset( + { + "title", "description", "importance", "due_at", "start_date", + "estimate_mins", "status", + } +) + #: Very conservative "this looks like a secret" patterns — publish refuses a #: graph containing one anywhere in node config (platform contract rung 3: #: secrets live in the Integration Registry, never in workflow JSON). @@ -425,6 +445,46 @@ def _validate_node_config( issues.append(GraphIssue("missing_config", "set node needs assignments", nid)) elif ntype == "wait": _validate_wait_config(nid, config, issues) + elif ntype == "pm_task": + _validate_pm_task_config(nid, config, issues) + + +def _validate_pm_task_config( + nid: str, config: dict[str, Any], issues: list[GraphIssue] +) -> None: + """A task node needs a task to act on and something to do to it. + + Both checks land at PUBLISH rather than at run time, which is the whole + point of the gate: an automation whose target is missing should fail while + the maker is looking at the canvas, not silently at 3am against a task that + turns out not to exist. + + Field NAMES are checked here; field values are not, because a value may be + a ``{{ref}}`` that only resolves during a run. + """ + if not str(config.get("task_id") or "").strip(): + issues.append( + GraphIssue("missing_config", "task node needs a task id", nid) + ) + fields = config.get("fields") + if not isinstance(fields, dict) or not fields: + issues.append( + GraphIssue( + "missing_config", + "task node needs at least one field to set", + nid, + ) + ) + return + unknown = sorted(k for k in fields if k not in PM_TASK_FIELDS) + if unknown: + issues.append( + GraphIssue( + "unknown_field", + f"task node cannot set {unknown} — one of {sorted(PM_TASK_FIELDS)}", + nid, + ) + ) def _validate_wait_config(nid: str, config: dict[str, Any], issues: list[GraphIssue]) -> None: diff --git a/apps/services/gateway/gateway/routes/workflows/engine/handlers.py b/apps/services/gateway/gateway/routes/workflows/engine/handlers.py index 8a132ba6d..01e75e7ca 100644 --- a/apps/services/gateway/gateway/routes/workflows/engine/handlers.py +++ b/apps/services/gateway/gateway/routes/workflows/engine/handlers.py @@ -36,6 +36,8 @@ "wait": WAIT_INLINE_MAX_SECONDS + 10.0, "output": 5.0, "trigger": 5.0, + # An internal DB write — nothing here crosses the network. + "pm_task": 30.0, } DEFAULT_NODE_TIMEOUT = 60.0 @@ -57,6 +59,18 @@ class NodeServices: run_agent: Callable[[str, str, str | None], Awaitable[str]] run_tool: Callable[[str, dict[str, Any], str], Awaitable[dict[str, Any]]] get_module_code: Callable[[str], Awaitable[str | None]] + #: ``update_task(task_id, fields)`` → what changed. Injected like every + #: other seam, so the engine never imports the Projects app and a test can + #: drive a task node with no database at all. + #: + #: Deliberately takes NO actor: the engine says *what* to change, the + #: wiring decides *who* is changing it — and for an automation that is + #: always the workflow itself, never whoever tripped the trigger. Passing + #: an actor the implementation would have to ignore is how the two answers + #: start disagreeing. + update_task: Callable[ + [str, dict[str, Any]], 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) @@ -171,14 +185,7 @@ async def execute_node( return {"result": result, "branch": "true" if result else "false"} if ntype == "set": - assignments = ( - config.get("assignments") if isinstance(config.get("assignments"), dict) else {} - ) - resolved = {str(k): resolve_value(v, state) for k, v in assignments.items()} - vars_bucket = state.setdefault("vars", {}) - if isinstance(vars_bucket, dict): - vars_bucket.update(resolved) - return resolved + return _execute_set(config, state) if ntype == "approval": # The runner pauses unresolved approvals before this handler runs; @@ -196,8 +203,60 @@ async def execute_node( await asyncio.sleep(seconds) return {"waited_seconds": seconds} + if ntype == "pm_task": + return await _execute_pm_task(config, state, services) + if ntype == "output": value = resolve_value(config.get("value"), state) return {"value": value} raise NodeExecutionError(f"unknown node type '{ntype}'") + + +def _execute_set(config: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: + """Assign values into the run's `vars` bucket. + + Extracted unchanged when the task node landed: `execute_node` sat exactly + at the complexity ceiling, so one more branch had to be paid for rather + than the ceiling raised. Behaviour is identical — the golden trajectory + eval covers it. + """ + assignments = ( + config.get("assignments") if isinstance(config.get("assignments"), dict) else {} + ) + resolved = {str(k): resolve_value(v, state) for k, v in assignments.items()} + vars_bucket = state.setdefault("vars", {}) + if isinstance(vars_bucket, dict): + vars_bucket.update(resolved) + return resolved + + +async def _execute_pm_task( + config: dict[str, Any], + state: dict[str, Any], + services: NodeServices, +) -> dict[str, Any]: + """The Projects task node (WS-27f / §13 U1). + + A separate function rather than a fifth branch inline: `execute_node` is + already at the complexity ceiling, and this node's run-time checks are the + interesting part rather than boilerplate worth burying. + """ + if services.update_task is None: + raise NodeExecutionError("the Projects app is not available") + task_id = str(resolve_value(config.get("task_id"), state) or "").strip() + if not task_id: + raise NodeExecutionError("task node needs a task id") + if "{{" in task_id: + # `templating.resolve_value` keeps an unresolvable ref AS-IS at run time + # BY DESIGN — design-time validation is where refs are flagged — and + # `{{trigger.missing}}` passes that gate because its ROOT is legal. + # Left alone the literal reaches the database as a would-be uuid and + # comes back "Task not found", sending the maker to look for a task + # rather than at their reference. + raise NodeExecutionError(f"task id did not resolve: '{task_id}'") + raw_fields = config.get("fields") if isinstance(config.get("fields"), dict) else {} + fields = resolve_value(raw_fields, state) + if not isinstance(fields, dict) or not fields: + raise NodeExecutionError("task node needs at least one field to set") + return await services.update_task(task_id, fields) diff --git a/apps/services/gateway/gateway/routes/workflows/service.py b/apps/services/gateway/gateway/routes/workflows/service.py index 7ba20b8f4..728d63acc 100644 --- a/apps/services/gateway/gateway/routes/workflows/service.py +++ b/apps/services/gateway/gateway/routes/workflows/service.py @@ -149,12 +149,45 @@ async def _get_module_code(module_id: str) -> str | None: return row.code if row is not None else None -def build_node_services(actor: str) -> NodeServices: +def _pm_task_updater(workflow_id: str) -> Any: + """The Projects write seam, with this workflow's identity bound in. + + The import is inside the closure so the workflows package gains no + import-time dependency on an app package, and so a deployment without + Projects fails the one node that needs it rather than the whole engine. + """ + + async def _update(task_id: str, fields: dict[str, Any]) -> dict[str, Any]: + try: + from gateway.routes.projects.automation import ( + TaskPatchError, + apply_task_patch, + 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 apply_task_patch( + db, task_id, fields, actor=workflow_actor(workflow_id), + ) + await db.commit() + return result + except TaskPatchError as exc: + raise NodeExecutionError(str(exc)) from exc + finally: + await db.close() + + return _update + + +def build_node_services(actor: str, workflow_id: str = "") -> NodeServices: return NodeServices( run_agent=_run_agent_node, run_tool=execute_tool, get_module_code=_get_module_code, actor=actor, + update_task=_pm_task_updater(workflow_id), ) @@ -361,7 +394,7 @@ def emit(node_id: str, status: str, detail: dict[str, Any]) -> None: outcome = await execute_workflow( serialized, trigger_payload, - build_node_services(actor), + build_node_services(actor, workflow_id), variables=variables, emit=emit, precomputed=precomputed, diff --git a/infra/postgres/147_projects_personal.sql b/infra/postgres/147_projects_personal.sql new file mode 100644 index 000000000..91fb08f71 --- /dev/null +++ b/infra/postgres/147_projects_personal.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- 147_projects_personal.sql — the personal lens, on the SAME task store. +-- +-- What: spec ai-company-brain/specs/project_management_app.md §3.11–§3.12 and +-- §6.1 (WS-27e). Two additions to migration 146's spine: +-- * pm_projects.personal_owner — a project that belongs to one member +-- * pm_task_personal — that member's GTD overlay on a task +-- +-- Why: **D-PM-6 was superseded on 2026-08-06** (owner-directed: "the personal +-- task manager should be a proper extension of the project system … a +-- cohesive whole"). The original decision mirrored pm_tasks into gtd_items as +-- SYNCED rows. A mirror is two rows for one fact, and every feature built on +-- top of it — search, calendar, agents, reporting — then has to know about +-- both. So there is now ONE task table: assigning a task to somebody does not +-- *copy* it into their inbox, it IS the row in their inbox. +-- +-- The two things that made a separate personal store look necessary, and how +-- they are answered here instead: +-- +-- 1. "Private todos aren't org work." They are now a **personal project** — an +-- ordinary pm_projects row with `personal_owner` set, granted to that one +-- email. Nothing about the task, board, timeline or automation machinery +-- needs a special case; it is a project like any other whose grant happens +-- to name a person. +-- 2. "The GTD overlay is mine, not the team's." It is, so it lives in a +-- **per-member** side table. Two people assigned the same task can hold +-- different dispositions — one is doing it (NEXT), one is waiting on it +-- (WAITING) — which a single column on pm_tasks could never express. That +-- is not an edge case; it is what delegation looks like. +-- +-- gtd_items is now LEGACY. `/tasks` reads a union of both during coexistence +-- and WS-27h retires it (spec §7.5). This migration does not touch it. +-- +-- Idempotent per infra/postgres/README.md. Pinned by +-- tests/unit/test_projects_personal.py, which reads this file as TEXT. +-- +-- Depends on: 146_projects.sql (pm_projects, pm_tasks). +-- ============================================================================ + +-- ── A project that belongs to one person ───────────────────────────────── §3.11 +-- +-- NULL = an ordinary team project. Non-null = somebody's personal project, and +-- the address IS the meaning — there is no `kind` column beside it, because two +-- columns that must agree are two columns that can disagree. +-- +-- The row still gets an ordinary `pm_project_grants` entry naming the same +-- email. This column is not the ACL; it is how "find MY personal project" +-- resolves in one indexed read rather than by scanning grants. + +ALTER TABLE pm_projects + ADD COLUMN IF NOT EXISTS personal_owner TEXT; + +-- One personal project per member, enforced case-insensitively because every +-- read compares folded (R10). Partial, so the millions of team projects that +-- will never carry the column cost nothing. +CREATE UNIQUE INDEX IF NOT EXISTS uq_pm_projects_personal_owner + ON pm_projects (lower(personal_owner)) + WHERE personal_owner IS NOT NULL; + +-- ── The per-member GTD overlay ─────────────────────────────────────────── §3.12 +-- +-- The vocabulary is migration 48's, unchanged and deliberately so: WS-27h has +-- to move ~every gtd_items row onto these columns, and a renamed disposition +-- would make that migration a translation instead of a copy. +-- +-- Every column is NULLABLE and NULL means "not stated". That is what preserves +-- the contract the ClickUp sync has always kept — the overlay is never +-- clobbered — now made structural: the org side of a task cannot write here at +-- all, and the personal side cannot write to pm_tasks' shared columns except +-- through the ordinary task routes. +-- +-- ⚠️ `disposition` NULL is not INBOX. A member who has never triaged a task +-- assigned to them has no disposition, and the read DERIVES one from the task's +-- status category (the same lens the ClickUp pull applies today). Defaulting +-- the column to 'INBOX' would make "never triaged" and "deliberately filed to +-- the inbox" the same state, and the weekly review cannot tell them apart. + +CREATE TABLE IF NOT EXISTS pm_task_personal ( + task_id UUID NOT NULL REFERENCES pm_tasks (id) ON DELETE CASCADE, + -- An email, folded on write. Never `agent:`: an agent has no GTD + -- practice, and a CHECK is not used because the API is the writer and a + -- constraint here would duplicate a rule that lives with the identity. + member_email TEXT NOT NULL, + disposition TEXT CHECK (disposition IN ( + 'INBOX', 'NEXT', 'WAITING', 'SOMEDAY', + 'PROJECT', 'REFERENCE', 'DONE', 'TRASH')), + -- The clarified physical next action, in this member's words. + next_action TEXT, + context TEXT, + energy TEXT CHECK (energy IN ('low', 'medium', 'high')), + time_estimate_mins INT, + is_two_minute BOOLEAN NOT NULL DEFAULT false, + -- Tickler: hidden from the active inbox until this instant. + defer_until TIMESTAMPTZ, + -- When this member last triaged it. The Weekly Review's "what have I not + -- looked at" question is this column, and it is why triage is recorded even + -- when the member changes nothing. + clarified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (task_id, member_email) +); + +-- The inbox read is "my rows, by disposition, oldest first" — the same shape as +-- migration 48's idx_gtd_items_user_disposition, for the same query. +CREATE INDEX IF NOT EXISTS idx_pm_task_personal_member + ON pm_task_personal (member_email, disposition); +-- The tickler sweep: "what has become due to resurface". +CREATE INDEX IF NOT EXISTS idx_pm_task_personal_defer + ON pm_task_personal (defer_until) WHERE defer_until IS NOT NULL; +-- Context lists ("@calls, next actions only") — partial, because that is the +-- only disposition the context picker is ever asked about. +CREATE INDEX IF NOT EXISTS idx_pm_task_personal_context + ON pm_task_personal (member_email, context) + WHERE disposition = 'NEXT'; diff --git a/infra/postgres/148_people_key_shape.sql b/infra/postgres/148_people_key_shape.sql new file mode 100644 index 000000000..8b255d8b7 --- /dev/null +++ b/infra/postgres/148_people_key_shape.sql @@ -0,0 +1,155 @@ +-- 148_people_key_shape.sql — fix gtd_people's key shape (People Center P-1/P-2). +-- +-- What: (1) a stable per-source upsert key (`source_key`) so the HR importer +-- keeps working once names stop being unique; (2) a partial UNIQUE on +-- lower(email) so the email→person join is unambiguous, with conflicting +-- addresses QUARANTINED rather than dropped; (3) UNIQUE(name) removed so +-- two people may share a name; (4) a status vocabulary CHECK. +-- Why: migration 49 made `name` UNIQUE and left `email` unconstrained. That is +-- backwards for a directory that has to join on email: two real people +-- cannot share a name (they do), and nothing stopped two rows carrying the +-- same address — so an email join could silently attribute one person's +-- capacity to another. The People Center is about to make this table the +-- assignment source, so the shape is fixed before anything relies on it. +-- Spec: ai-company-brain/specs/people_center_app.md §2, §5 (P-1/P-2), §7. +-- Depends on: 49_gtd_people.sql, 74_gtd_people_editable_and_resumes.sql. +-- +-- ⚠️ NOTHING HERE MAY BLOCK A DEPLOY. `apply_migrations.sh` runs under +-- `set -euo pipefail` + `ON_ERROR_STOP=1`, so a statement that fails against +-- real data stops the whole deploy — twice already this month (the +-- lock_timeout prelude, the backup-prune dir). Both new constraints could +-- plausibly fail on live rows, so both are written to make the data legal +-- first, non-destructively, instead of failing and demanding a human. +-- +-- Idempotent: IF NOT EXISTS everywhere; the backfills are guarded so a re-run +-- is a no-op; the quarantine cannot re-quarantine an already-cleared row. + +-- ── 1. A per-source upsert key ────────────────────────────────────────────── +-- `scripts/import_hr_people.py` upserts `ON CONFLICT (name)`. Dropping +-- UNIQUE(name) without giving it another key would break the importer at run +-- time with "no unique or exclusion constraint matching the ON CONFLICT +-- specification" — the consequence P-1 did not name. +-- +-- The honest key is per-SOURCE, not per-person: the HR snapshot is a JSON +-- object keyed by name, so names are unique *within that file* whether or not +-- they are unique among humans. `source_key` says exactly that and nothing more. +ALTER TABLE gtd_people ADD COLUMN IF NOT EXISTS source_key TEXT; + +-- Backfilled BEFORE UNIQUE(name) is dropped, while name is still guaranteed +-- distinct — which is what makes the backfill collision-free by construction. +UPDATE gtd_people + SET source_key = COALESCE(NULLIF(btrim(source), ''), 'manual') || ':' || lower(btrim(name)) + WHERE source_key IS NULL AND name IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_gtd_people_source_key + ON gtd_people (source_key) WHERE source_key IS NOT NULL; + +-- ── 2. Email: normalise, quarantine, then constrain ───────────────────────── +-- Where a duplicate address is found the LOSER's address is moved aside rather +-- than deleted. Losing an address silently would be worse than the ambiguity +-- this migration exists to remove, and a deploy that aborts because two rows +-- share an address would be worse than both. +ALTER TABLE gtd_people ADD COLUMN IF NOT EXISTS email_conflict TEXT; + +COMMENT ON COLUMN gtd_people.email_conflict IS + 'An address moved aside because another row already claimed it (migration 148). ' + 'Non-null means a human still has to decide which row is the real person.'; + +-- '' is not NULL, so two blank emails would collide under the unique index. +UPDATE gtd_people SET email = NULL + WHERE email IS NOT NULL AND btrim(email) = ''; + +-- A trailing space makes two identical addresses look distinct to a human and +-- to `lower(email)` alike. +UPDATE gtd_people SET email = btrim(email) + WHERE email IS NOT NULL AND email <> btrim(email); + +-- Winner = most recently updated, then most recently created, then lowest id. +-- Deterministic on purpose: an arbitrary winner would make a re-run against a +-- restored backup pick differently and move a different row's address. +WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY lower(email) + ORDER BY updated_at DESC NULLS LAST, + created_at DESC NULLS LAST, + id + ) AS rn + FROM gtd_people + WHERE email IS NOT NULL +) +UPDATE gtd_people p + SET email_conflict = COALESCE(p.email_conflict, p.email), + email = NULL + FROM ranked r + WHERE r.id = p.id AND r.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_gtd_people_email_lower + ON gtd_people (lower(email)) WHERE email IS NOT NULL; + +-- ── 3. Names stop being unique ────────────────────────────────────────────── +-- Looked up by SHAPE rather than assuming Postgres's `gtd_people_name_key` +-- default, so a database whose constraint was created under another name is +-- still cleaned up instead of silently keeping it. +DO $$ +DECLARE + cname text; +BEGIN + SELECT c.conname INTO cname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'gtd_people' + AND c.contype = 'u' + AND ( + SELECT array_agg(a.attname ORDER BY a.attname) + FROM unnest(c.conkey) AS k + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k + ) = ARRAY['name'] + LIMIT 1; + IF cname IS NOT NULL THEN + EXECUTE format('ALTER TABLE gtd_people DROP CONSTRAINT %I', cname); + END IF; +END $$; + +-- Name lookups are still common (the directory searches by name), and the +-- dropped constraint took its implicit index with it. +CREATE INDEX IF NOT EXISTS idx_gtd_people_name_lower ON gtd_people (lower(name)); + +-- ── 4. The status vocabulary ──────────────────────────────────────────────── +-- Migration 49 documented `'active' | 'inactive' | …` — the "…" is the problem. +-- Known legacy spellings are mapped; anything else is left alone rather than +-- rewritten, because silently reclassifying a status nobody anticipated is a +-- data loss disguised as a cleanup. +UPDATE gtd_people SET status = 'active' + WHERE status IS NULL OR btrim(status) = ''; +UPDATE gtd_people SET status = 'alumni' + WHERE lower(btrim(status)) IN ('inactive', 'former', 'left'); +UPDATE gtd_people SET status = lower(btrim(status)) + WHERE status <> lower(btrim(status)); + +-- Added NOT VALID, then validated separately. NOT VALID enforces every future +-- INSERT and UPDATE while tolerating a pre-existing row outside the vocabulary, +-- so an unanticipated legacy value cannot stop the deploy. If the data is clean +-- the VALIDATE below promotes it to a fully checked constraint in the same run. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'gtd_people' AND c.conname = 'gtd_people_status_check' + ) THEN + ALTER TABLE gtd_people + ADD CONSTRAINT gtd_people_status_check + CHECK (status IN ('active', 'contractor', 'alumni', 'invited')) NOT VALID; + END IF; +END $$; + +DO $$ +BEGIN + ALTER TABLE gtd_people VALIDATE CONSTRAINT gtd_people_status_check; +EXCEPTION WHEN check_violation THEN + -- Enforced for every new write; existing offenders stay readable and are + -- listed by the directory's own data-quality panel rather than by a failed + -- deploy at 3am. + RAISE NOTICE 'gtd_people_status_check left NOT VALID: existing rows carry a status outside the vocabulary'; +END $$; diff --git a/infra/postgres/149_people.sql b/infra/postgres/149_people.sql new file mode 100644 index 000000000..64c27a276 --- /dev/null +++ b/infra/postgres/149_people.sql @@ -0,0 +1,32 @@ +-- 149_people.sql — register the People Center's directory as a feature. +-- +-- What: the `feature_catalog` row for `people` (`/people`), the surface WS-28b +-- builds: directory, person page, org chart. +-- Why: the directory has to be reachable and grantable on its own. It cannot +-- ride `feature:tasks`, which is what gates the existing +-- `/tasks/people` API, because the People Center is a different audience: +-- a manager who needs the org chart and the assignee picker should not +-- have to be given the personal GTD task manager to get them. +-- Spec: ai-company-brain/specs/people_center_app.md §6. +-- Registration is FIVE places and this is one of them — the others are +-- `acb_auth.permissions.FEATURES`, `nav.ts`, `access.ts` and +-- `centers.ts`; a both-ways test fails if this row and FEATURES disagree. +-- Depends on: the feature_catalog table (130), 148_people_key_shape.sql. +-- +-- Posture: `is_default false`, exactly like `crm` and `projects`. The directory +-- is open to HOLDERS of the feature; the HR-sensitive half of a person +-- record (skills, résumé, capacity) stays restricted by +-- `admin:members:read` — a projection WS-24 N4 already shipped and which +-- WS-28 must not re-implement. +-- Idempotent: ON CONFLICT DO UPDATE, and `is_default` is deliberately NOT +-- overwritten (the rule 130/144/146 already follow): an admin may have +-- retuned the default set and a redeploy must not stomp it. + +INSERT INTO feature_catalog (slug, label, description, nav_href, category, sort_order, is_default) VALUES + ('people', 'People', 'Directory, skills and org chart', '/people', 'apps', 57, false) +ON CONFLICT (slug) DO UPDATE + SET label = EXCLUDED.label, + description = EXCLUDED.description, + nav_href = EXCLUDED.nav_href, + category = EXCLUDED.category, + sort_order = EXCLUDED.sort_order; diff --git a/infra/postgres/150_projects_attachments.sql b/infra/postgres/150_projects_attachments.sql new file mode 100644 index 000000000..fe04cf0f3 --- /dev/null +++ b/infra/postgres/150_projects_attachments.sql @@ -0,0 +1,78 @@ +-- 150_projects_attachments.sql — files on a project task (WS-27i). +-- +-- What: pm_task_attachments — a thin join from a task to a row in the EXISTING +-- `gtd_attachments` file registry, plus `attachment` added to the +-- `pm_activities` type vocabulary. +-- Why: spec §11.2 item 1, the first ClickUp-parity gap: "a task with a photo +-- of the failed print is the normal case in a hardware company, and today +-- there is nowhere to put it." +-- Spec: ai-company-brain/specs/project_management_app.md §11. +-- Depends on: 52_gtd_attachments.sql (the file registry), 146_projects.sql. +-- +-- **One file store, not two.** Paca's shape is a central `files` registry plus +-- a thin per-domain join (research §2.7), and `gtd_attachments` already IS that +-- registry — owner, name, mime, size, disk path. A second table with a second +-- storage directory would mean two places to back up, two size limits to keep +-- in step, and two answers to "is this extension allowed". +-- +-- **What differs is who may READ, and that is the whole reason for the join.** +-- `gtd_attachments` is owner-scoped end to end: `/tasks/attachments/{id}/{name}` +-- serves only to the uploader, which is right for a personal capture and wrong +-- for a shared task. This join is what makes a file readable by everyone who +-- can see the task — so the *join*, not the file row, carries the access +-- decision, and the personal serve route is left exactly as it was. +-- +-- Idempotent: IF NOT EXISTS everywhere; the CHECK is replaced through a guarded +-- DROP/ADD pair because Postgres has no ADD CONSTRAINT IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS pm_task_attachments ( + task_id UUID NOT NULL REFERENCES pm_tasks (id) ON DELETE CASCADE, + -- No FK to gtd_attachments ON DELETE CASCADE by accident: deleting the file + -- row should not silently remove the task's record that a file was there. + -- The API detaches explicitly, and a dangling id serves a 404 rather than + -- rewriting history. + attachment_id UUID NOT NULL, + added_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (task_id, attachment_id) +); + +CREATE INDEX IF NOT EXISTS idx_pm_task_attachments_task + ON pm_task_attachments (task_id); +-- The serve route asks "is this attachment on ANY task I can see", which is a +-- lookup by attachment rather than by task. +CREATE INDEX IF NOT EXISTS idx_pm_task_attachments_attachment + ON pm_task_attachments (attachment_id); + +-- `attachment` joins the activity vocabulary so adding a file lands on the same +-- timeline as a comment or a status move. A file that appears with no trace of +-- who added it or when is the kind of gap the single activity spine exists to +-- close (§3.8). +-- Dropped by SHAPE, not by assuming Postgres's `pm_activities_type_check` +-- default. Dropping a name that does not exist is a silent no-op, and the ADD +-- below would then succeed under a *second* name — leaving the old, narrower +-- CHECK in force alongside the new one. Both would apply, `attachment` would +-- still be rejected, and the migration would report success. Finding every +-- CHECK that constrains this vocabulary is the only version that cannot do +-- that. +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_activities' + AND c.contype = 'c' + AND pg_get_constraintdef(c.oid) LIKE '%status_change%' + LOOP + EXECUTE format('ALTER TABLE pm_activities DROP CONSTRAINT %I', cname); + END LOOP; + + ALTER TABLE pm_activities + ADD CONSTRAINT pm_activities_type_check + CHECK (type IN ('comment', 'status_change', 'field_change', + 'link', 'assignment', 'agent_run', + 'sync', 'system', 'attachment')); +END $$; diff --git a/packages/acb_auth/acb_auth/permissions.py b/packages/acb_auth/acb_auth/permissions.py index 5a6534e86..aa9d57cf6 100644 --- a/packages/acb_auth/acb_auth/permissions.py +++ b/packages/acb_auth/acb_auth/permissions.py @@ -90,6 +90,14 @@ # scopes DATA as well as navigation — the grant model in §4 is what decides # which projects a holder actually sees. "projects", + # The People Center's directory, person page and org chart (149_people.sql, + # category `apps`, sort_order 57: after Projects, so the work surfaces and + # the people behind them sit together). NOT a member default, same posture + # as `crm` and `projects` (specs/people_center_app.md §6). The directory + # itself is open to holders; the HR-sensitive half of a person record stays + # restricted by `admin:members:read`, a projection WS-24 N4 already shipped + # and which this feature must NOT re-implement. + "people", "dashboard", "observability", "artifacts", diff --git a/scripts/import_hr_people.py b/scripts/import_hr_people.py index be5cd14d9..a404583ca 100644 --- a/scripts/import_hr_people.py +++ b/scripts/import_hr_people.py @@ -22,17 +22,41 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +#: The source this importer owns. Part of `source_key`, so a row hand-added in +#: the People Center is never overwritten by a snapshot re-import. +SOURCE = "agent-project-manager" + + +def source_key(name: str) -> str: + """The importer's upsert key — per SOURCE, not per person. + + Migration 148 dropped `UNIQUE(name)` from `gtd_people` (People Center P-1): + two real people may share a name, and the old constraint said they could + not. This script still needs *some* stable key or a re-import would insert a + second copy of everyone, and `ON CONFLICT (name)` now has no constraint to + infer — it would fail outright. + + `source_key` is the honest one. The HR snapshot is a JSON object keyed by + name, so names are unique **within that file** whether or not they are + unique among humans; this key claims exactly that and nothing more. + """ + return f"{SOURCE}:{name.strip().lower()}" + + UPSERT = """ INSERT INTO gtd_people (name, email, role, department, team, reports_to, status, skills, resume_summary, years_experience, domain, capacity_hours_per_week, current_load_hours_per_week, - available_hours_per_week, clickup_user_id, synced_at, updated_at) + available_hours_per_week, clickup_user_id, source, source_key, + synced_at, updated_at) VALUES (:name, :email, :role, :department, :team, :reports_to, :status, :skills, :resume_summary, :years_experience, :domain, - :capacity, :load, :available, :clickup_user_id, now(), now()) -ON CONFLICT (name) DO UPDATE SET + :capacity, :load, :available, :clickup_user_id, :source, :source_key, + now(), now()) +ON CONFLICT (source_key) WHERE source_key IS NOT NULL DO UPDATE SET + name = EXCLUDED.name, email = COALESCE(EXCLUDED.email, gtd_people.email), role = EXCLUDED.role, department = EXCLUDED.department, @@ -85,6 +109,8 @@ def build_rows(hr: dict, resumes: dict) -> list[dict]: skills.append(s) rows.append({ "name": name, + "source": SOURCE, + "source_key": source_key(name), "email": m.get("email") or prof.get("email"), "role": m.get("role"), "department": dept.get("name"), @@ -108,6 +134,23 @@ def build_rows(hr: dict, resumes: dict) -> list[dict]: if m.get("clickup_user_id") is not None else None ), }) + + # Migration 148 put a partial UNIQUE on lower(email). Two snapshot members + # carrying the same address would now fail the whole import on a unique + # violation, so the duplicate is dropped to NULL here — at the source, where + # the row is still identifiable — rather than discovered as a psql error. + # First occurrence keeps the address; the person still imports, they just + # arrive without an email until somebody says which one is theirs. + seen_emails: set[str] = set() + for row in rows: + address = (row.get("email") or "").strip().lower() + if not address: + row["email"] = None + continue + if address in seen_emails: + row["email"] = None + continue + seen_emails.add(address) return rows diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py index 205aac755..147062835 100644 --- a/tests/unit/_projects_fakes.py +++ b/tests/unit/_projects_fakes.py @@ -226,6 +226,13 @@ def _now() -> datetime: "project_id": None, "deleted_at": None}, "pm_views": {"view_type": "list", "config": {}, "position": None}, "pm_view_task_positions": {"group_key": None}, + # Composite key, no `id` column — the upsert path keys on (task_id, + # member_email) via ON CONFLICT, which _insert already handles generically. + "pm_task_personal": { + "disposition": None, "next_action": None, "context": None, + "energy": None, "time_estimate_mins": None, "is_two_minute": False, + "defer_until": None, "clarified_at": None, + }, } _TIMESTAMPED = { @@ -447,6 +454,14 @@ def _select(self, statement: str, table: str, args: dict) -> _Result: # answered explicitly rather than silently returning project rows with # no `subject` attribute — which is how a "no previous mapping" answer # would look identical to a broken query. + # The personal inbox (147/§6.1) joins pm_tasks to its status, to the + # caller's overlay row and to the project, and computes `is_mine` and + # `assignee_count`. That is past what a text mirror can read, so the + # shape is answered explicitly — like the mapping join below. The + # DISCRIMINATOR is the overlay join, which no other statement carries. + if "LEFT JOIN pm_task_personal p" in statement: + return _Result(self._inbox_rows(statement, args)) + # Matched on the JOIN specifically: the visibility CTE also names both # tables, and a looser check short-circuited every scoped read. if "JOIN pm_project_grants g" in statement: @@ -509,6 +524,72 @@ def _subtree_ids(self, root_id: str) -> set[str]: changed = True return out + def _inbox_rows(self, statement: str, args: dict) -> list[Any]: + """The personal inbox's joined shape, computed in Python. + + Mirrors the SQL's own arms rather than restating them loosely: a task is + mine if I am assigned OR its project is my personal one — and the second + arm is the one that keeps a self-captured task visible after I clear my + own name off it. + + The two optional clauses are keyed off the statement text, so a route + that stops emitting them stops being filtered here too. + """ + who = str(args.get("who") or "").lower() + personal_projects = { + str(p["id"]) for p in self.rows("pm_projects") + if str(p.get("personal_owner") or "").lower() == who + } + statuses = {str(s["id"]): s for s in self.rows("pm_task_statuses")} + overlay = { + str(o["task_id"]): o for o in self.rows("pm_task_personal") + if str(o.get("member_email") or "").lower() == who + } + + # Each arm is applied ONLY when the statement carries it. Applying them + # unconditionally is what let a mutant delete the personal-project arm + # from the SQL with every test still green — the exact failure this + # module's docstring warns about, caught by mutation rather than review. + wants_assigned = "lower(a.assignee) = :who" in statement + wants_personal = "lower(proj.personal_owner) = :who" in statement + + out: list[Any] = [] + for task in self.rows("pm_tasks"): + if task.get("archived_at") is not None: + continue + assignees = self._assignees_of(task["id"]) + reached = (wants_assigned and who in assignees) or ( + wants_personal and str(task.get("project_id")) in personal_projects + ) + if not reached: + continue + + mine = overlay.get(str(task["id"]), {}) + if "p.defer_until IS NULL OR p.defer_until <= now()" in statement: + deferred = mine.get("defer_until") + if deferred is not None and _as_datetime(deferred) > _now(): + continue + if "lower(p.context) = :context" in statement: + wanted = str(args.get("context") or "").lower() + if str(mine.get("context") or "").lower() != wanted: + continue + + status = statuses.get(str(task.get("status_id")), {}) + out.append(SimpleNamespace( + **task, + status_category=status.get("category", ""), + p_disposition=mine.get("disposition"), + p_next_action=mine.get("next_action"), + p_context=mine.get("context"), + p_energy=mine.get("energy"), + p_time_estimate_mins=mine.get("time_estimate_mins"), + p_is_two_minute=bool(mine.get("is_two_minute", False)), + p_defer_until=mine.get("defer_until"), + assignee_count=len(assignees), + is_mine=who in assignees, + )) + return out + def _assignees_of(self, task_id: str) -> set[str]: return { str(a.get("assignee") or "").lower() @@ -663,6 +744,14 @@ def _paged(self, statement: str, rows: list[dict], args: dict) -> list[dict]: return rows +def _as_datetime(value: Any) -> datetime: + """A stored `defer_until` as an aware instant, however it was written.""" + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=UTC) + parsed = datetime.fromisoformat(str(value)) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + def _sortable(value: Any) -> Any: """A total order across the mixed types one column can hold in a fake.""" if value is None: diff --git a/tests/unit/test_org_access_control.py b/tests/unit/test_org_access_control.py index ba980499a..1c603b79d 100644 --- a/tests/unit/test_org_access_control.py +++ b/tests/unit/test_org_access_control.py @@ -498,6 +498,25 @@ def test_crm_is_registered_on_both_sides() -> None: assert "crm" in _catalog_slugs() +def test_projects_is_registered_on_both_sides() -> None: + """WS-27a's registration, which was left to the generic pair and should not + have been — for exactly the reason stated above: those pass when BOTH sides + are missing a slug, so they cannot catch a feature nobody registered.""" + assert "projects" in FEATURES + assert "projects" in _catalog_slugs() + + +def test_people_is_registered_on_both_sides() -> None: + """WS-28b's registration (`people_center_app.md` §6, step 4, by name). + + The People Center's directory needs its OWN slug rather than riding + `feature:tasks`: a manager who needs the org chart and the assignee picker + should not have to be handed the personal GTD task manager to get them. + """ + assert "people" in FEATURES + assert "people" in _catalog_slugs() + + def test_agent_service_is_not_assignable_to_people() -> None: assert "agent_service" in SYSTEM_ROLES assert "agent_service" not in ASSIGNABLE_SYSTEM_ROLES diff --git a/tests/unit/test_org_access_enforcement.py b/tests/unit/test_org_access_enforcement.py index 6f39b84f2..d20528cc2 100644 --- a/tests/unit/test_org_access_enforcement.py +++ b/tests/unit/test_org_access_enforcement.py @@ -155,6 +155,11 @@ def test_service_principal_passes_the_gate() -> None: # a route that lost its gate would expose another department's work, not # just a nav pane. "gateway.routes.projects": set(), + # The People Center's directory. Registered here rather than left out, + # because this registry is hand-maintained: a router absent from it is not + # "passing", it is unchecked — and this one serves a person's skills, + # capacity and open work. + "gateway.routes.people": set(), "gateway.routes.chat": set(), "gateway.routes.actions": set(), "gateway.routes.integrations": set(), diff --git a/tests/unit/test_people_directory.py b/tests/unit/test_people_directory.py new file mode 100644 index 000000000..b31a92ec8 --- /dev/null +++ b/tests/unit/test_people_directory.py @@ -0,0 +1,462 @@ +"""WS-28b — the People Center's directory and person page. + +Spec: `ai-company-brain/specs/people_center_app.md` §3.1, §3.2, §5.2, §6. + +The claims worth testing are about the *shape of the answer*, because this +app's whole permission story is a projection rather than a refusal: + +* the HR half comes back nulled without `admin:members:read`, and the response + SAYS SO — "you may not see this" and "nobody filled it in" are different + facts and a blank field cannot tell them apart; +* the search box cannot become an oracle for the field the projection hides; +* the work panel is scoped by the VIEWER's project grants, not the subject's; +* load is computed from open assigned tasks, never read from the typed column. + +**Why a local double rather than `_projects_fakes`.** That fake models the +statement shapes the Projects routes emit; these routes emit aggregates, +`GROUP BY`s and a cross-table existence check it was never built for. Teaching +it those shapes to serve one suite is how a shared fake becomes a second, worse +implementation of Postgres — the exact mirror-failure its own docstring warns +about. The double below answers by statement fingerprint and does nothing else. + +The genuinely interesting decisions — *which clauses exist at all* — are pure +and are tested directly against `build_directory_filters`, because proving them +through any fake would prove them about the fake. + +Hermetic: no Postgres, no network. +""" + +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import HTTPException +from gateway.routes.people import core as people_core +from gateway.routes.people import directory as people_directory +from gateway.routes.people.directory import build_directory_filters + +REPO = Path(__file__).resolve().parents[2] + + +def run(coro): + return asyncio.run(coro) + + +# ── The double ────────────────────────────────────────────────────────────── + +class _Result: + def __init__(self, rows: list[Any]): + self._rows = rows + + def fetchone(self): + return self._rows[0] if self._rows else None + + def fetchall(self): + return list(self._rows) + + +class FakeDB: + """Answers by statement fingerprint. Deliberately dumb. + + Every canned answer is set by the test that needs it, so a test cannot pass + on a row some earlier fixture happened to leave lying around. + """ + + def __init__(self) -> None: + self.people: list[Any] = [] + self.logins: set[str] = set() + self.load_row: Any = SimpleNamespace(open_tasks=0, mins=0, unestimated=0) + self.work: list[Any] = [] + self.facets: list[Any] = [] + self.statements: list[str] = [] + self.params: list[dict] = [] + + async def execute(self, sql: Any, params: dict | None = None) -> _Result: + statement = " ".join(str(sql).split()) + self.statements.append(statement) + self.params.append(dict(params or {})) + if "FROM app_user" in statement: + wanted = (params or {}).get("email") + return _Result([SimpleNamespace(x=1)] if wanted in self.logins else []) + if "AS open_tasks" in statement: + return _Result([self.load_row]) + if "GROUP BY department" in statement: + return _Result(self.facets) + if "FROM pm_tasks" in statement: + return _Result(self.work) + if "SELECT email FROM gtd_people" in statement or "FROM gtd_people" in statement: + return _Result(self.people) + return _Result([]) + + async def commit(self) -> None: # pragma: no cover - reads only + return None + + async def close(self) -> None: + return None + + +def person_row(**columns) -> SimpleNamespace: + base = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Rahul", "email": "r@x.in", "role": "Engineer", "title": None, + "department": "Engineering", "team": "Firmware", "reports_to": None, + "manager_id": None, "status": "active", "skills": ["Firmware"], + "skills_source": {}, "domain": None, "resume_summary": "5y embedded", + "years_experience": 5, "capacity_hours_per_week": 40, + "current_load_hours_per_week": 30, "available_hours_per_week": 10, + "clickup_user_id": None, "email_conflict": None, + } + base.update(columns) + return SimpleNamespace(**base) + + +@pytest.fixture() +def db() -> FakeDB: + return FakeDB() + + +@pytest.fixture(autouse=True) +def bind(monkeypatch, db): + async def _get_db(): + return db + + for module in (people_core, people_directory): + monkeypatch.setattr(module, "_get_db", _get_db, raising=False) + + +def _user(email: str, *grants: str): + """A principal built with the REAL `build_access`, so the permissions the + routes check are the ones this caller actually resolves — a hand-stubbed + `has_permission` would prove only that the stub agrees with itself.""" + from acb_auth import UserContext, UserRole, build_access + + return UserContext( + email=email, role=UserRole.EMPLOYEE, access=build_access(list(grants)), + ) + + +def hr_reader(email: str = "admin@fracktal.in"): + return _user(email, "feature:people", "feature:projects", "admin:members:read") + + +def plain(email: str = "member@fracktal.in"): + """A directory holder with neither HR read nor the Projects app. + + The principal every projection test needs: without it the suite proves only + that a `*`-holder sees everything, which is true of an unprojected route. + """ + return _user(email, "feature:people") + + +# ── The filter rules (pure) ───────────────────────────────────────────────── + +def test_search_matches_the_basic_directory_for_everyone(): + clauses, params = build_directory_filters(hr=False, q="rahul") + joined = " ".join(clauses) + assert "name ILIKE :q" in joined and "department ILIKE :q" in joined + assert params["q"] == "%rahul%" + + +def test_search_cannot_become_an_oracle_for_the_hidden_field(): + """Matching on a column that is then stripped from the response would let a + caller confirm a skill they may not read.""" + allowed = " ".join(build_directory_filters(hr=True, q="crypto")[0]) + denied = " ".join(build_directory_filters(hr=False, q="crypto")[0]) + assert "unnest(skills)" in allowed + assert "unnest(skills)" not in denied + + +def test_the_skill_filter_is_dropped_without_the_hr_permission(): + allowed, allowed_params = build_directory_filters(hr=True, skill="crypto") + denied, denied_params = build_directory_filters(hr=False, skill="crypto") + assert "unnest(skills)" in " ".join(allowed) + assert allowed_params["skill"] == "%crypto%" + assert "unnest(skills)" not in " ".join(denied) + assert "skill" not in denied_params + + +def test_has_capacity_is_dropped_without_the_hr_permission(): + """Derived from the restricted capacity trio, so it is the same rule.""" + assert "available_hours_per_week" in " ".join( + build_directory_filters(hr=True, has_capacity=True)[0] + ) + assert "available_hours_per_week" not in " ".join( + build_directory_filters(hr=False, has_capacity=True)[0] + ) + + +def test_the_basic_filters_apply_regardless_of_hr_permission(): + for hr in (True, False): + clauses, params = build_directory_filters( + hr=hr, department="Engineering", team="Firmware", status="active", + ) + joined = " ".join(clauses) + assert "lower(department)" in joined + assert "lower(team)" in joined + assert "status = :status" in joined + # Case-folded, so a filter chip typed either way finds the same people. + assert params["department"] == "engineering" + + +def test_no_filters_means_no_predicates(): + clauses, params = build_directory_filters(hr=True) + assert clauses == ["true"] + assert params == {} + + +def test_a_blank_search_is_not_a_search(): + assert build_directory_filters(hr=True, q=" ")[0] == ["true"] + + +# ── The directory route ───────────────────────────────────────────────────── + +def test_the_directory_states_whether_the_hr_half_is_visible(db): + db.people = [person_row()] + assert run(people_directory.list_directory(user=hr_reader())).hr_visible is True + assert run(people_directory.list_directory(user=plain())).hr_visible is False + + +def test_skills_come_back_nulled_without_the_hr_permission(db): + db.people = [person_row(skills=["Firmware", "C++"])] + denied = run(people_directory.list_directory(user=plain())) + assert denied.rows[0]["skills"] == [] + assert denied.rows[0]["resume_summary"] is None + # …and the basic half survives: this is a projection, not a refusal. Someone + # who cannot see skills can still find the person. + assert denied.rows[0]["name"] == "Rahul" + assert denied.rows[0]["department"] == "Engineering" + + +def test_an_unknown_status_filter_is_a_422_not_an_empty_list(db): + """An empty list would answer a typo as a fact — "nobody is a contracter".""" + with pytest.raises(HTTPException) as err: + run(people_directory.list_directory(user=hr_reader(), status="contracter")) + assert err.value.status_code == 422 + + +# ── The person page ───────────────────────────────────────────────────────── + +def test_a_person_with_an_app_user_row_is_badged_as_having_a_login(db): + db.people = [person_row(email="R@X.in")] + db.logins = {"r@x.in"} + detail = run(people_directory.get_person("pid", user=hr_reader())) + # Case-insensitive on both sides (R10) — the badge must not depend on how + # the address happened to be typed. + assert detail["has_login"] is True + + +def test_a_directory_only_person_is_badged_as_such(db): + db.people = [person_row(email="contractor@x.in")] + detail = run(people_directory.get_person("pid", user=hr_reader())) + assert detail["has_login"] is False + + +def test_a_quarantined_address_is_surfaced_not_hidden(db): + """Migration 148 moves a duplicate aside; a human still has to decide which + row is the real person, and they cannot if nothing says so.""" + db.people = [person_row(email=None, email_conflict="shared@x.in")] + detail = run(people_directory.get_person("pid", user=hr_reader())) + assert detail["email_conflict"] == "shared@x.in" + + +def test_a_missing_person_is_a_404(db): + with pytest.raises(HTTPException) as err: + run(people_directory.get_person("nope", user=hr_reader())) + assert err.value.status_code == 404 + + +def test_load_is_absent_entirely_without_the_hr_permission(db): + db.people = [person_row()] + detail = run(people_directory.get_person("pid", user=plain())) + assert detail["load"] is None + assert detail["hr_visible"] is False + + +# ── Computed load (§5.2) ──────────────────────────────────────────────────── + +def test_load_is_computed_from_open_assigned_tasks(db): + """`current_load_hours_per_week` is a number somebody typed once and is + stale the moment anyone assigns anything.""" + db.load_row = SimpleNamespace(open_tasks=3, mins=120, unestimated=0) + load = run(people_directory.compute_load(db, "R@X.in")) + assert load == {"open_tasks": 3, "estimated_hours": 2.0, "unestimated": 0} + # …and it asks about the right person, case-folded. + assert db.params[-1]["who"] == "r@x.in" + + +def test_the_load_query_excludes_closed_work(db): + run(people_directory.compute_load(db, "r@x.in")) + statement = db.statements[-1] + assert "NOT IN ('done', 'cancelled')" in statement + assert "archived_at IS NULL" in statement + + +def test_unestimated_tasks_are_counted_even_though_they_add_no_hours(db): + """Without this count, a bar built from the sum shows somebody holding + thirty un-estimated tasks as completely free.""" + db.load_row = SimpleNamespace(open_tasks=30, mins=0, unestimated=30) + load = run(people_directory.compute_load(db, "r@x.in")) + assert load["estimated_hours"] == 0.0 + assert load["unestimated"] == 30 + + +def test_somebody_with_no_address_has_no_computable_load(db): + """Assignment targets are addresses, so there is nothing to look up — and + no query is issued at all.""" + load = run(people_directory.compute_load(db, None)) + assert load == {"open_tasks": 0, "estimated_hours": 0.0, "unestimated": 0} + assert db.statements == [] + + +# ── The work panel ────────────────────────────────────────────────────────── + +def test_the_work_panel_says_unavailable_without_the_projects_feature(db): + """"This surface is not yours" and "they have nothing open" must not render + identically.""" + work = run(people_directory.get_person_work("pid", user=plain())) + assert work.available is False + assert work.rows == [] + # And it does not even ask the database. + assert db.statements == [] + + +def test_the_work_panel_is_available_but_empty_for_an_addressless_person(db): + db.people = [SimpleNamespace(email=None)] + work = run(people_directory.get_person_work("pid", user=hr_reader())) + assert work.available is True + assert work.total == 0 + + +def test_the_work_panel_lists_open_tasks(db): + db.people = [SimpleNamespace(email="r@x.in")] + db.work = [SimpleNamespace( + id="t1", title="Ship it", task_number=7, due_at=None, + project_id="p1", project_name="Delivery", + status_name="Doing", status_category="in_progress", + )] + work = run(people_directory.get_person_work("pid", user=hr_reader())) + assert work.total == 1 + assert work.rows[0]["project_name"] == "Delivery" + + +def test_the_work_panel_is_scoped_by_the_VIEWERS_grants(db, monkeypatch): + """A Sales lead looking at an Operations colleague sees the work they + share, not that person's whole life — so a restricted viewer's query must + carry the grant-closure predicate.""" + db.people = [SimpleNamespace(email="r@x.in")] + work_sql: list[str] = [] + + async def fake_resolve(_db, _user): + from gateway.routes.projects.core import Visibility + + return Visibility(unrestricted=False, email="me@x.in", groups=("group:sales",)) + + monkeypatch.setattr( + "gateway.routes.projects.core.resolve_visibility", fake_resolve + ) + run(people_directory.get_person_work("pid", user=hr_reader())) + work_sql = [s for s in db.statements if "FROM pm_tasks" in s] + assert "root_project_id IN" in work_sql[-1] + assert db.params[-1]["vis_email"] == "me@x.in" + + +def test_an_unrestricted_viewer_gets_no_scoping_predicate(db, monkeypatch): + """`data:org:read` is the full-portfolio view; adding the closure anyway + would put a recursive join on every read for no effect.""" + db.people = [SimpleNamespace(email="r@x.in")] + + async def fake_resolve(_db, _user): + from gateway.routes.projects.core import Visibility + + return Visibility(unrestricted=True, email="", groups=()) + + monkeypatch.setattr( + "gateway.routes.projects.core.resolve_visibility", fake_resolve + ) + run(people_directory.get_person_work("pid", user=hr_reader())) + work_sql = [s for s in db.statements if "FROM pm_tasks" in s][-1] + assert "root_project_id IN" not in work_sql + + +def test_the_work_panel_404s_for_a_person_who_does_not_exist(db): + with pytest.raises(HTTPException) as err: + run(people_directory.get_person_work("nope", user=hr_reader())) + assert err.value.status_code == 404 + + +# ── Facets ────────────────────────────────────────────────────────────────── + +def test_facets_are_derived_from_the_rows(db): + """A department list somebody has to maintain goes stale the first time the + org changes.""" + db.facets = [ + SimpleNamespace(department="Engineering", team="Firmware", total=2), + SimpleNamespace(department="Engineering", team="Cloud", total=1), + SimpleNamespace(department="Sales", team=None, total=1), + ] + facets = run(people_directory.list_facets(user=hr_reader())) + assert {d["department"]: d["total"] for d in facets["departments"]} == { + "Engineering": 3, "Sales": 1, + } + assert {t["team"] for t in facets["teams"]} == {"Firmware", "Cloud"} + assert facets["statuses"] == list(people_core.STATUSES) + + +# ── Registration (§6) ─────────────────────────────────────────────────────── + +def test_the_feature_catalog_row_exists_and_points_at_the_app(): + """Found by CONTENT, not by migration number — R1 forbids writing an + absolute future number anywhere, so a test pinned to `149_` would be the + same mistake in a different file.""" + hits = [ + p for p in (REPO / "infra" / "postgres").glob("*.sql") + if "'people', 'People'" in p.read_text(encoding="utf-8") + ] + assert len(hits) == 1, hits + raw = hits[0].read_text(encoding="utf-8") + # Comments stripped before asserting — this file's header *explains* the + # is_default rule at length, so a naive substring check passes on the prose + # even after the statement stops honouring it. Same trap WS-28a's migration + # fence hit; assertions must read what Postgres will execute. + sql = "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + assert "'/people'" in sql + # Same posture as crm and projects: reachable only once granted. + assert "false)" in sql + # A redeploy must not stomp an admin's retuned default set. + assert "is_default" not in sql.split("ON CONFLICT")[1] + + +def test_the_router_gates_on_its_own_feature(): + """Not `feature:tasks`: the People Center is a different audience.""" + assert people_core.router.prefix == "/people" + assert people_core.router.dependencies, "the router carries no gate at all" + + +def test_the_hr_permission_is_imported_not_redefined(): + """§6: "a restriction that already exists and must not be re-implemented + here". Two definitions of "may this caller see skills" are two answers + waiting to drift.""" + from gateway.routes.tasks.core import can_read_hr_fields as source + + assert people_core.can_read_hr_fields is source + + +def test_the_person_row_shape_is_not_reimplemented(): + """The directory renders `_row_to_person`'s projection, so a field added to + the restricted set is hidden here automatically rather than by memory.""" + assert people_directory._row_to_person.__module__ == "gateway.routes.tasks.people" + + +def test_the_status_vocabulary_matches_the_migrations_check(): + assert people_core.STATUSES == ("active", "contractor", "alumni", "invited") + + +def test_has_login_is_false_for_a_blank_address(db): + assert run(people_core.has_login(db, " ")) is False + assert run(people_core.has_login(db, None)) is False + assert db.statements == [] diff --git a/tests/unit/test_people_key_shape.py b/tests/unit/test_people_key_shape.py new file mode 100644 index 000000000..57b9937a0 --- /dev/null +++ b/tests/unit/test_people_key_shape.py @@ -0,0 +1,248 @@ +"""WS-28a — gtd_people's key shape (People Center P-1/P-2). + +Spec: `ai-company-brain/specs/people_center_app.md` §2, §5, §7. + +Migration 49 made `name` UNIQUE and left `email` unconstrained. That is +backwards for a directory that has to join on email: two real people cannot +share a name (they do), and nothing stopped two rows carrying the same address — +so an email→person join could silently attribute one person's capacity to +another. This fixes the shape *before* the People Center makes this table the +assignment source. + +**Why the migration half is static.** `infra/postgres/README.md` requires every +`02+` migration to be idempotent because `apply_migrations.sh` replays the whole +ladder on every deploy — and it runs under `set -euo pipefail` + `ON_ERROR_STOP=1`, +so a statement that fails against real data stops the deploy. Reading the file is +what makes both properties enforceable in CI rather than "by inspection". + +The file is found by CONTENT, not by number: R1 forbids writing an absolute +future migration number anywhere, so a test pinned to `148_` would be the same +class of mistake in a different file. +""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +MIGRATIONS = REPO / "infra" / "postgres" + + +@pytest.fixture(scope="module") +def sql() -> str: + """The key-shape migration's STATEMENTS — comments stripped. + + Stripping is not tidiness. This file's header explains at length why the + CHECK is `NOT VALID` and why a conflicting address is quarantined, so a + naive `"NOT VALID" in sql` passes on the *prose* even after the keyword is + deleted from the statement — which is exactly the mutant that survived the + first time this file was written. Assertions must read what Postgres will + execute, never what the file says about itself. + """ + hits = [ + p for p in MIGRATIONS.glob("*.sql") + if "uq_gtd_people_email_lower" in p.read_text(encoding="utf-8") + ] + assert len(hits) == 1, f"expected exactly one key-shape migration, found {hits}" + raw = hits[0].read_text(encoding="utf-8") + return "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + + +@pytest.fixture(scope="module") +def importer(): + spec = importlib.util.spec_from_file_location( + "import_hr_people", REPO / "scripts" / "import_hr_people.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# ── P-1: the constraints ──────────────────────────────────────────────────── + +def test_email_gains_a_partial_unique_index(sql: str): + """Partial, because a directory row may legitimately have no email at all — + a contractor in the org chart who has never had a login.""" + assert re.search( + r"CREATE UNIQUE INDEX IF NOT EXISTS uq_gtd_people_email_lower\s+" + r"ON gtd_people \(lower\(email\)\) WHERE email IS NOT NULL", + sql, + ) + + +def test_the_unique_constraint_on_name_is_dropped(sql: str): + assert "DROP CONSTRAINT" in sql + # By SHAPE, not by Postgres's default `gtd_people_name_key` — a database + # whose constraint was created under another name must still be cleaned up. + assert "c.contype = 'u'" in sql + assert "ARRAY['name']" in sql + + +def test_dropping_the_name_constraint_leaves_an_index_behind(sql: str): + """The dropped UNIQUE took its implicit index with it, and the directory + searches by name — so the lookup must not silently become a seq scan.""" + assert "idx_gtd_people_name_lower" in sql + + +# ── P-2: the status vocabulary ────────────────────────────────────────────── + +def test_status_gains_the_four_value_check(sql: str): + assert re.search( + r"CHECK \(status IN \('active', 'contractor', 'alumni', 'invited'\)\)", sql + ) + + +def test_the_status_check_is_added_NOT_VALID(sql: str): + """The deploy-safety half. + + A plain CHECK against a legacy value nobody anticipated would abort the + migration and, under ON_ERROR_STOP=1, the entire deploy. NOT VALID enforces + every future write while tolerating an existing offender. + """ + assert re.search( + r"ADD CONSTRAINT gtd_people_status_check\s+CHECK \([^;]*\) NOT VALID\s*;", sql + ) + assert "VALIDATE CONSTRAINT gtd_people_status_check" in sql + # …and the validation itself must not be able to fail the deploy either. + assert "EXCEPTION WHEN check_violation" in sql + + +def test_legacy_status_values_are_mapped_not_guessed(sql: str): + assert "'inactive'" in sql and "'alumni'" in sql + + +# ── Deploy safety ─────────────────────────────────────────────────────────── + +def test_every_new_object_is_created_idempotently(sql: str): + """`apply_migrations.sh` replays this on every deploy.""" + for statement in re.findall(r"CREATE (?:UNIQUE )?INDEX[^;]*;", sql): + assert "IF NOT EXISTS" in statement, statement + for statement in re.findall(r"ALTER TABLE gtd_people ADD COLUMN[^;]*;", sql): + assert "IF NOT EXISTS" in statement, statement + + +def test_the_status_constraint_is_guarded_against_re_adding(sql: str): + """`ADD CONSTRAINT` has no `IF NOT EXISTS` in Postgres, so it needs a guard + or the second deploy fails.""" + assert "IF NOT EXISTS (" in sql + assert "gtd_people_status_check" in sql + + +def test_a_conflicting_address_is_moved_aside_not_deleted(sql: str): + """Losing an address silently would be worse than the ambiguity this + migration exists to remove.""" + assert "email_conflict" in sql + assert re.search(r"SET email_conflict = COALESCE\(p\.email_conflict, p\.email\)", sql) + + +def test_the_duplicate_winner_is_chosen_deterministically(sql: str): + """An arbitrary winner would make a re-run against a restored backup pick + differently and move a *different* row's address.""" + assert "ORDER BY updated_at DESC NULLS LAST" in sql + assert "created_at DESC NULLS LAST" in sql + + +def test_blank_emails_are_nulled_before_the_index_is_built(sql: str): + """`''` is not NULL, so two blank addresses would collide under a partial + index that only excludes NULL.""" + assert re.search(r"SET email = NULL\s*\n?\s*WHERE email IS NOT NULL AND btrim\(email\) = ''", sql) + + +def test_source_key_is_backfilled_before_the_name_constraint_is_dropped(sql: str): + """Order is load-bearing: the backfill is collision-free ONLY while `name` + is still guaranteed distinct.""" + backfill = sql.index("SET source_key =") + drop = sql.index("DROP CONSTRAINT") + assert backfill < drop + + +# ── The importer this would otherwise break ───────────────────────────────── + +def test_the_importer_no_longer_upserts_on_name(importer): + """`ON CONFLICT (name)` needs a unique constraint on `name` to infer. With + that constraint gone it fails outright — the consequence P-1 did not name.""" + assert "ON CONFLICT (name)" not in importer.UPSERT + assert "ON CONFLICT (source_key)" in importer.UPSERT + + +def test_the_importer_upsert_matches_the_partial_index_predicate(importer): + """Postgres can only infer a PARTIAL unique index when the statement repeats + its predicate. Without this the upsert raises at run time, not at import.""" + assert "WHERE source_key IS NOT NULL" in importer.UPSERT + + +def test_the_upsert_refreshes_the_name(importer): + """Names are now mutable — a person can be renamed in the snapshot without + becoming a second row, which is exactly what the old key made impossible.""" + assert re.search(r"DO UPDATE SET\s*\n\s*name = EXCLUDED\.name", importer.UPSERT) + + +def test_source_key_is_scoped_to_this_importer(importer): + """So a person hand-added in the People Center is never overwritten by a + snapshot re-import.""" + assert importer.source_key("Rahul") == "agent-project-manager:rahul" + + +def test_source_key_is_stable_across_spacing_and_case(importer): + assert importer.source_key(" RAHUL ") == importer.source_key("rahul") + + +def _snapshot(members: list[dict]) -> dict: + return {"company": "X", "departments": [ + {"name": "Engineering", "head": "Vijay", + "teams": [{"name": "Firmware", "members": members}]} + ]} + + +def test_every_imported_row_carries_its_key_and_source(importer): + rows = importer.build_rows(_snapshot([{"name": "Rahul", "email": "r@x.in"}]), {}) + assert rows[0]["source_key"] == "agent-project-manager:rahul" + assert rows[0]["source"] == "agent-project-manager" + + +def test_two_people_may_share_a_name_and_still_import(importer): + """The point of P-1. Both rows exist; they differ by source_key only in that + the snapshot itself cannot express two people of one name — but nothing in + the schema stops them any more.""" + rows = importer.build_rows( + _snapshot([ + {"name": "Priya", "email": "priya.a@x.in"}, + {"name": "Priya", "email": "priya.b@x.in"}, + ]), + {}, + ) + assert len(rows) == 2 + assert {r["email"] for r in rows} == {"priya.a@x.in", "priya.b@x.in"} + + +def test_a_duplicate_address_is_dropped_at_the_source_not_at_psql(importer): + """The unique index would otherwise fail the whole import on the second row. + First occurrence keeps the address; the other person still imports.""" + rows = importer.build_rows( + _snapshot([ + {"name": "Rahul", "email": "shared@x.in"}, + {"name": "Sam", "email": "SHARED@x.in"}, + ]), + {}, + ) + assert len(rows) == 2 + assert rows[0]["email"] == "shared@x.in" + assert rows[1]["email"] is None + + +def test_a_blank_address_becomes_null_rather_than_empty_string(importer): + rows = importer.build_rows(_snapshot([{"name": "Rahul", "email": " "}]), {}) + assert rows[0]["email"] is None + + +def test_two_people_without_addresses_both_import(importer): + """Regression guard on the dedup: NULL is not a duplicate of NULL.""" + rows = importer.build_rows( + _snapshot([{"name": "Rahul"}, {"name": "Sam"}]), {} + ) + assert len(rows) == 2 + assert all(r["email"] is None for r in rows) diff --git a/tests/unit/test_projects_attachments.py b/tests/unit/test_projects_attachments.py new file mode 100644 index 000000000..bc62430d3 --- /dev/null +++ b/tests/unit/test_projects_attachments.py @@ -0,0 +1,412 @@ +"""WS-27i — files on a project task (spec §11.2 item 1). + +The interesting claims here are all about **who may read the bytes**, because +this feature deliberately does *not* inherit the file store's own answer: + +* `gtd_attachments` is owner-scoped — `/tasks/attachments/{id}/{name}` serves + only to the uploader. Right for a private capture, useless for a shared task. +* So the JOIN carries the decision: a file is readable by anyone who can see a + task it hangs off. Two properties follow, and both are security properties + rather than conveniences: + - **there is no attach-by-id endpoint** — upload and attach are one call, so + nobody can attach somebody else's private capture to a task they own and + read it back through the project route; + - **a personal capture stays unreachable** here, because it has no join row. + +Hermetic: a purpose-built double; no Postgres, no disk beyond a tmp_path. +""" + +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import attachments as pm_attachments + +REPO = Path(__file__).resolve().parents[2] + + +def run(coro): + return asyncio.run(coro) + + +class _Result: + def __init__(self, rows: list[Any], rowcount: int = 0): + self._rows = rows + self.rowcount = rowcount + + def fetchone(self): + return self._rows[0] if self._rows else None + + def fetchall(self): + return list(self._rows) + + +class FakeDB: + """Answers by statement fingerprint; records what it was asked.""" + + def __init__(self) -> None: + self.serve_row: Any = None + self.listed: list[Any] = [] + self.deleted = 0 + self.statements: list[str] = [] + self.params: list[dict] = [] + self.committed = 0 + + async def execute(self, sql: Any, params: dict | None = None) -> _Result: + statement = " ".join(str(sql).split()) + self.statements.append(statement) + self.params.append(dict(params or {})) + if statement.startswith("DELETE FROM pm_task_attachments"): + return _Result([], rowcount=self.deleted) + if "FROM pm_task_attachments ta JOIN pm_tasks t" in statement: + return _Result([self.serve_row] if self.serve_row else []) + if "FROM pm_task_attachments ta JOIN gtd_attachments" in statement: + return _Result(self.listed) + return _Result([]) + + async def commit(self) -> None: + self.committed += 1 + + async def close(self) -> None: + return None + + def sql_touching(self, needle: str) -> list[str]: + return [s for s in self.statements if needle in s] + + +class UploadStub: + def __init__(self, filename: str, content: bytes, content_type: str = "image/png"): + self.filename = filename + self.content_type = content_type + self._content = content + self.reads = 0 + + async def read(self) -> bytes: + self.reads += 1 + return self._content + + +@pytest.fixture() +def db() -> FakeDB: + return FakeDB() + + +@pytest.fixture(autouse=True) +def wiring(monkeypatch, db, tmp_path): + async def _get_db(): + return db + + async def _resolve(_db, _user): + from gateway.routes.projects.core import Visibility + + return Visibility(unrestricted=False, email="me@x.in", groups=("group:ops",)) + + async def _load_visible(_db, _vis, task_id): + if task_id == "denied": + raise HTTPException(status_code=404, detail="No such task") + return SimpleNamespace(id=task_id, project_id="p1", root_project_id="p1") + + async def _record(*_a, **_k): + return None + + async def _emit(*_a, **_k): + return None + + monkeypatch.setattr(pm_attachments, "_get_db", _get_db) + monkeypatch.setattr(pm_attachments, "resolve_visibility", _resolve) + monkeypatch.setattr(pm_attachments, "load_visible_task", _load_visible) + monkeypatch.setattr(pm_attachments, "record_activity", _record) + monkeypatch.setattr(pm_attachments, "emit", _emit) + monkeypatch.setattr(pm_attachments, "_storage_dir", lambda: tmp_path) + return tmp_path + + +def user(email: str = "me@x.in"): + from acb_auth import UserContext, UserRole, build_access + + return UserContext( + email=email, role=UserRole.EMPLOYEE, + access=build_access(["feature:projects"]), + ) + + +# ── The access model ──────────────────────────────────────────────────────── + +def test_there_is_no_attach_by_id_endpoint(): + """The escalation this design refuses: naming an arbitrary attachment id + would let a caller attach somebody else's private capture to a task they + own and then read it. Upload and attach are ONE call, so the only way a row + enters the join is by supplying the bytes.""" + paths = { + getattr(r, "path", "") for r in pm_attachments.router.routes + if "attachment" in getattr(r, "path", "") + } + for path in paths: + methods = { + m for r in pm_attachments.router.routes + if getattr(r, "path", "") == path + for m in getattr(r, "methods", set()) + } + # A POST that does NOT carry an upload would be the attach-by-id shape. + if "POST" in methods: + assert path.endswith("/attachments"), path + + +def test_serving_asks_whether_the_caller_can_see_a_task_it_hangs_off(db): + db.serve_row = SimpleNamespace(name="x.png", mime="image/png", path="/nope") + with pytest.raises(HTTPException): + run(pm_attachments.serve_attachment("a1", "x.png", user=user())) + sql = db.sql_touching("FROM pm_task_attachments ta JOIN pm_tasks t")[-1] + # The grant closure, not the file's owner column. + assert "root_project_id IN" in sql + assert "user_id" not in sql + + +def test_an_unrestricted_viewer_gets_no_scoping_predicate(db, monkeypatch): + async def _resolve(_db, _user): + from gateway.routes.projects.core import Visibility + + return Visibility(unrestricted=True, email="", groups=()) + + monkeypatch.setattr(pm_attachments, "resolve_visibility", _resolve) + db.serve_row = None + with pytest.raises(HTTPException): + run(pm_attachments.serve_attachment("a1", "x.png", user=user())) + sql = db.sql_touching("FROM pm_task_attachments ta JOIN pm_tasks t")[-1] + assert "root_project_id IN" not in sql + + +def test_an_attachment_on_no_visible_task_is_a_404_not_a_403(db): + """R5: a 403 would confirm the file exists.""" + db.serve_row = None + with pytest.raises(HTTPException) as err: + run(pm_attachments.serve_attachment("a1", "x.png", user=user())) + assert err.value.status_code == 404 + + +def test_a_missing_file_on_disk_is_a_404_even_when_the_row_exists(db): + db.serve_row = SimpleNamespace(name="x.png", mime="image/png", + path="/definitely/not/here.png") + with pytest.raises(HTTPException) as err: + run(pm_attachments.serve_attachment("a1", "x.png", user=user())) + assert err.value.status_code == 404 + + +# ── Upload ────────────────────────────────────────────────────────────────── + +def test_visibility_is_checked_BEFORE_the_bytes_are_read(db): + """Otherwise an unauthorised caller can still make the server read and + validate a 15MB upload.""" + upload = UploadStub("photo.png", b"x" * 10) + with pytest.raises(HTTPException): + run(pm_attachments.attach_file("denied", upload, user=user())) + assert upload.reads == 0 + + +def test_an_upload_writes_the_file_row_and_the_join(db, wiring): + upload = UploadStub("photo.png", b"bytes") + result = run(pm_attachments.attach_file("t1", upload, user=user())) + assert result["kind"] == "image" + assert result["name"] == "photo.png" + assert result["url"].startswith("/api/projects/attachments/") + assert db.sql_touching("INSERT INTO gtd_attachments") + assert db.sql_touching("INSERT INTO pm_task_attachments") + assert db.committed == 1 + # …and the bytes actually landed. + written = list(Path(wiring).iterdir()) + assert len(written) == 1 + assert written[0].read_bytes() == b"bytes" + + +def test_the_uploader_is_recorded_as_the_file_owner_and_the_adder(db): + run(pm_attachments.attach_file("t1", UploadStub("a.png", b"z"), user=user("her@x.in"))) + file_params = db.params[db.statements.index( + db.sql_touching("INSERT INTO gtd_attachments")[0] + )] + assert file_params["uid"] == "her@x.in" + join_params = db.params[db.statements.index( + db.sql_touching("INSERT INTO pm_task_attachments")[0] + )] + assert join_params["who"] == "her@x.in" + + +def test_an_executable_is_refused(db): + """Imported from the capture flow's list, not re-decided here.""" + with pytest.raises(HTTPException) as err: + run(pm_attachments.attach_file("t1", UploadStub("evil.exe", b"MZ"), user=user())) + assert err.value.status_code == 400 + assert db.sql_touching("INSERT INTO gtd_attachments") == [] + + +def test_an_empty_file_is_refused(db): + with pytest.raises(HTTPException) as err: + run(pm_attachments.attach_file("t1", UploadStub("a.png", b""), user=user())) + assert err.value.status_code == 400 + + +def test_an_oversized_file_is_refused_with_413(db): + big = b"x" * (pm_attachments._MAX_BYTES + 1) + with pytest.raises(HTTPException) as err: + run(pm_attachments.attach_file("t1", UploadStub("a.png", big), user=user())) + assert err.value.status_code == 413 + assert db.sql_touching("INSERT INTO gtd_attachments") == [] + + +def test_the_disk_path_cannot_be_steered_by_the_filename(db, wiring): + """Safe BY CONSTRUCTION, not by sanitisation: the destination is + ``, so the supplied name never reaches the path at all.""" + run(pm_attachments.attach_file( + "t1", UploadStub("../../etc/passwd.png", b"z"), user=user(), + )) + written = list(Path(wiring).iterdir()) + assert len(written) == 1 + assert written[0].parent == Path(wiring) + assert ".." not in written[0].name + + +def test_a_hostile_filename_is_sanitised_in_the_STORED_name(db): + """What `_safe_name` actually protects. + + The stored name is echoed into the descriptor, rendered in the UI, and + handed to `FileResponse(filename=…)`, which puts it in a + `Content-Disposition` header — so path separators, quotes and newlines + matter there even though the disk path is already safe. Asserting on the + disk path alone let a mutant that removed `_safe_name` entirely survive. + """ + result = run(pm_attachments.attach_file( + "t1", UploadStub('../../etc/pa"ss\nwd.png', b"z"), user=user(), + )) + assert "/" not in result["name"] + assert ".." not in result["name"] + assert '"' not in result["name"] + assert "\n" not in result["name"] + # …and the same sanitised name is what went into the file row and the URL. + stored = db.params[db.statements.index( + db.sql_touching("INSERT INTO gtd_attachments")[0] + )] + assert stored["name"] == result["name"] + assert result["url"].endswith(result["name"]) + + +def test_a_non_image_is_described_as_a_file(db): + result = run(pm_attachments.attach_file( + "t1", UploadStub("spec.pdf", b"%PDF", content_type="application/pdf"), + user=user(), + )) + assert result["kind"] == "file" + + +# ── Listing and detaching ─────────────────────────────────────────────────── + +def test_listing_requires_seeing_the_task(db): + with pytest.raises(HTTPException): + run(pm_attachments.list_attachments("denied", user=user())) + + +def test_listing_renders_the_descriptor_shape(db): + db.listed = [SimpleNamespace( + id="a1", name="photo.png", mime="image/png", size_bytes=12, + added_by="me@x.in", created_at=None, + )] + res = run(pm_attachments.list_attachments("t1", user=user())) + assert res.total == 1 + assert res.rows[0]["kind"] == "image" + assert res.rows[0]["url"] == "/api/projects/attachments/a1/photo.png" + + +def test_detaching_keeps_the_bytes(db): + """The same file may hang off another task; deleting the row from under it + would turn one person's tidy-up into somebody else's broken link.""" + db.deleted = 1 + run(pm_attachments.detach_file("t1", "a1", user=user())) + assert db.sql_touching("DELETE FROM pm_task_attachments") + assert db.sql_touching("DELETE FROM gtd_attachments") == [] + assert db.sql_touching("DELETE FROM pm_tasks") == [] + + +def test_detaching_something_already_gone_is_a_no_op_not_a_404(db): + """Paca's lenient-removes lesson — what makes a retry after a half-failed + request safe.""" + db.deleted = 0 + result = run(pm_attachments.detach_file("t1", "a1", user=user())) + assert result["removed"] == 0 + + +def test_detaching_requires_seeing_the_task(db): + with pytest.raises(HTTPException): + run(pm_attachments.detach_file("denied", "a1", user=user())) + + +# ── The shared validation is shared ───────────────────────────────────────── + +def test_the_upload_rules_are_imported_not_reimplemented(): + """One answer to "what may be uploaded" — the capture flow's.""" + from gateway.routes.tasks import attachments as capture + + assert pm_attachments._MAX_BYTES is capture._MAX_BYTES + assert pm_attachments._BLOCKED_EXT is capture._BLOCKED_EXT + assert pm_attachments._safe_name is capture._safe_name + # `_storage_dir` is redirected to tmp_path by this module's autouse fixture, + # so identity cannot be asserted at run time. Read the import instead — the + # property that matters is that there is no second definition, and a copy + # would show up here as an absent import rather than a passing identity. + source = Path(pm_attachments.__file__).read_text(encoding="utf-8") + assert re.search( + r"from gateway\.routes\.tasks\.attachments import \((?:[^)]*\n)*?\s*_storage_dir,", + source, + ) + assert "def _storage_dir" not in source + + +# ── The migration ─────────────────────────────────────────────────────────── + +@pytest.fixture(scope="module") +def sql() -> str: + hits = [ + p for p in (REPO / "infra" / "postgres").glob("*.sql") + if "pm_task_attachments" in p.read_text(encoding="utf-8") + ] + assert len(hits) == 1, hits + raw = hits[0].read_text(encoding="utf-8") + # Comments stripped: this file explains its own reasoning at length, and an + # assertion that passes on the prose is not an assertion. + return "\n".join(re.sub(r"--.*$", "", line) for line in raw.splitlines()) + + +def test_the_join_is_created_idempotently(sql: str): + assert "CREATE TABLE IF NOT EXISTS pm_task_attachments" in sql + for statement in re.findall(r"CREATE INDEX[^;]*;", sql): + assert "IF NOT EXISTS" in statement, statement + + +def test_deleting_a_task_takes_its_attachment_rows(sql: str): + assert re.search(r"task_id\s+UUID NOT NULL REFERENCES pm_tasks \(id\) ON DELETE CASCADE", sql) + + +def test_the_attachment_id_does_NOT_cascade(sql: str): + """Deleting a file row must not silently erase the task's record that a file + was there — the API detaches explicitly.""" + join = sql.split("CREATE TABLE IF NOT EXISTS pm_task_attachments")[1].split(";")[0] + assert "attachment_id" in join + assert "REFERENCES gtd_attachments" not in join + + +def test_the_activity_vocabulary_gains_attachment(sql: str): + assert "'attachment'" in sql + assert "ADD CONSTRAINT pm_activities_type_check" in sql + + +def test_the_old_check_is_dropped_by_SHAPE_not_by_name(sql: str): + """Dropping a name that does not exist is a silent no-op, and the ADD would + then succeed under a second name — leaving the old, narrower CHECK in force + alongside the new one. Both apply, `attachment` is still rejected, and the + migration reports success.""" + assert "pg_get_constraintdef" in sql + assert "contype = 'c'" in sql + assert "FOR cname IN" in sql diff --git a/tests/unit/test_projects_automation.py b/tests/unit/test_projects_automation.py new file mode 100644 index 000000000..8820226b1 --- /dev/null +++ b/tests/unit/test_projects_automation.py @@ -0,0 +1,454 @@ +"""WS-27f — the automation write seam and agent dispatch. + +Spec: `ai-company-brain/specs/project_management_app.md` §6.3/§6.4 · +`ai-company-brain/specs/workflows_app.md` §13 U1 and U7. + +Two halves, and they are deliberately independent: + +- **U1** — a `pm_task` node lets the one automation engine ACT on a task. It + writes through `projects/automation.apply_task_patch`, the same helpers a + human PATCH uses, so an automation's edit is indistinguishable in validation + and identical in the timeline. +- **U7** — assigning `agent:` starts a run, from an event sink rather + than from inside the assignment handler. + +Hermetic: `_get_db` is monkeypatched to a fake and the orchestrator is never +imported. Nothing here needs Docker or a database. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest +from gateway.routes.projects import agent_dispatch +from gateway.routes.projects.automation import ( + PATCHABLE_FIELDS, + TaskPatchError, + apply_task_patch, + resolve_status, + workflow_actor, +) +from gateway.routes.workflows.engine.graph import ( + NODE_TYPES, + PM_TASK_FIELDS, + validate_graph, +) +from gateway.routes.workflows.engine.handlers import ( + NodeExecutionError, + NodeServices, + execute_node, +) + +from tests.unit._projects_fakes import FakeProjectsDB, bind_db + +# ── Fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture() +def db() -> FakeProjectsDB: + return FakeProjectsDB() + + +@pytest.fixture() +def project(db: FakeProjectsDB): + """A project with three lanes and one task sitting in the first.""" + proj = db.seed_project(name="Delivery") + todo = db.seed_status(proj.id, name="To do", category="todo", is_default=True) + 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 + ) + task = db.seed_task(proj.id, todo.id, title="Ship the thing") + return SimpleNamespace( + project=proj, todo=todo, doing=doing, done=done, task=task + ) + + +def run(coro): + return asyncio.run(coro) + + +# ── The copied-vocabulary fence ───────────────────────────────────────────── + +def test_the_engine_and_the_app_agree_on_what_a_task_node_may_set(): + """`PM_TASK_FIELDS` is a COPY of the app's list, so it needs a fence. + + Both directions on purpose. One direction catches the engine refusing a + field the app supports (a maker sees "cannot set X" for something that + works); the other catches the engine publishing a field the app will reject + at run time, which is the worse half — it moves the failure from the canvas + to 3am. + """ + assert set(PATCHABLE_FIELDS) | {"status"} == PM_TASK_FIELDS + + +def test_structural_moves_are_not_patchable(): + """`project_id`/`parent_task_id` re-stamp `root_project_id` across a whole + subtree, which is the move endpoint's job. A node that could set them here + would leave tasks pointing at another project's status rows.""" + assert "project_id" not in PM_TASK_FIELDS + assert "parent_task_id" not in PM_TASK_FIELDS + # And status_id specifically: a status move is a TRANSITION, and `status` + # (by name) is the supported spelling. + assert "status_id" not in PM_TASK_FIELDS + + +# ── The actor ─────────────────────────────────────────────────────────────── + +def test_the_automation_actor_is_the_workflow_not_the_trigger(): + assert workflow_actor("wf-7") == "system:workflow:wf-7" + + +# ── resolve_status ────────────────────────────────────────────────────────── + +def test_status_resolves_by_lane_name_case_insensitively(db, project): + row = run(resolve_status(db, project.project.id, " sHiPpEd ")) + assert str(row.id) == str(project.done.id) + + +def test_status_falls_back_to_category(db, project): + """`"done"` keeps working on a project whose lane is called "Shipped" — + otherwise every reusable automation has to know each project's wording.""" + row = run(resolve_status(db, project.project.id, "done")) + assert str(row.id) == str(project.done.id) + + +def test_a_name_beats_a_category_when_both_could_match(db, project): + # Seed a lane literally named "todo" alongside the category `todo`. + named = db.seed_status( + project.project.id, name="todo", category="in_progress", + is_default=False, position=40, + ) + row = run(resolve_status(db, project.project.id, "todo")) + assert str(row.id) == str(named.id) + + +def test_an_unknown_status_names_the_lanes_that_do_exist(db, project): + with pytest.raises(TaskPatchError) as err: + run(resolve_status(db, project.project.id, "Finished")) + message = str(err.value) + assert "Finished" in message + # The whole point: the maker is told the vocabulary instead of guessing. + assert "Shipped" in message and "To do" in message + + +def test_an_empty_status_is_refused_before_it_scans(db, project): + with pytest.raises(TaskPatchError): + run(resolve_status(db, project.project.id, " ")) + + +# ── apply_task_patch ──────────────────────────────────────────────────────── + +def test_an_unknown_field_is_refused_and_names_the_legal_set(db, project): + with pytest.raises(TaskPatchError) as err: + run(apply_task_patch(db, project.task.id, {"clickup_id": "x"}, actor="a")) + assert "clickup_id" in str(err.value) + + +def test_a_field_change_writes_the_field_change_activity(db, project): + result = run(apply_task_patch( + db, project.task.id, {"title": "Ship it properly"}, + actor=workflow_actor("wf-1"), + )) + assert result["changed"] == ["title"] + assert result["skipped"] is False + activities = db.activities("field_change") + assert len(activities) == 1 + # Attribution is the automation, inside the one actor vocabulary. + assert activities[0]["created_by"] == "system:workflow:wf-1" + + +def test_setting_a_field_to_the_value_it_already_has_writes_nothing(db, project): + """The "already in target state" rule (Paca research §9). + + This is what makes a crashed automation walk safe to retry, and it is what + stops a workflow that fires on every task update from rewriting the same + value forever and filling the timeline with edits nobody made. + """ + result = run(apply_task_patch( + db, project.task.id, {"title": "Ship the thing"}, actor="a", + )) + assert result["skipped"] is True + assert result["changed"] == [] + assert db.activities("field_change") == [] + # And no UPDATE is issued at all. This is the half that a "did anything + # change?" assertion misses: `update_row` stamps `updated_at = now()`, so a + # redundant write is invisible in the diff but leaves the task looking + # freshly touched — and an automation firing on `pm.task.updated` would + # bump every task it inspected, forever. + assert db.statements_touching("UPDATE pm_tasks") == [] + + +def test_a_status_move_is_a_transition_not_a_column_write(db, project): + """Delegated to `apply_status_transition`, which owns `completed_at` and + the `status_change` activity. A node that wrote the column would look right + on the board and leave the timeline silent.""" + result = run(apply_task_patch( + db, project.task.id, {"status": "Shipped"}, actor=workflow_actor("wf-2"), + )) + assert result["changed"] == ["status"] + assert result["status"] == "Shipped" + moves = db.activities("status_change") + assert len(moves) == 1 + assert moves[0]["created_by"] == "system:workflow:wf-2" + # The transition's own effect, not the node's. + task = next(r for r in db.rows("pm_tasks") if str(r["id"]) == str(project.task.id)) + assert task["completed_at"] is not None + + +def test_moving_a_task_to_the_status_it_is_already_in_is_a_no_op(db, project): + result = run(apply_task_patch( + db, project.task.id, {"status": "To do"}, actor="a", + )) + assert result["skipped"] is True + assert db.activities("status_change") == [] + + +def test_one_node_sets_several_fields_at_once(db, project): + """Paca's consolidation lesson adopted directly: `update_task` replaced five + single-field actions. A per-field node set is the thing not to build.""" + result = run(apply_task_patch( + db, project.task.id, + {"title": "Renamed", "importance": 1, "status": "Doing"}, + actor="a", + )) + assert set(result["changed"]) == {"title", "importance", "status"} + assert len(db.activities("field_change")) == 1 + assert len(db.activities("status_change")) == 1 + + +def test_a_partly_redundant_patch_writes_only_the_part_that_changed(db, project): + run(apply_task_patch(db, project.task.id, {"title": "Renamed"}, actor="a")) + result = run(apply_task_patch( + db, project.task.id, {"title": "Renamed", "importance": 3}, actor="a", + )) + assert result["changed"] == ["importance"] + + +# ── Graph validation (the publish gate) ───────────────────────────────────── + +def _graph(config: dict) -> dict: + return { + "nodes": [ + {"id": "t", "type": "trigger", "data": {"config": {}}}, + {"id": "n", "type": "pm_task", "data": {"config": config}}, + ], + "edges": [{"id": "e", "source": "t", "target": "n"}], + } + + +def _codes(graph: dict) -> set[str]: + return {issue.code for issue in validate_graph(graph)} + + +def test_the_task_node_type_exists(): + assert "pm_task" in NODE_TYPES + + +def test_a_valid_task_node_publishes_clean(): + assert _codes(_graph({"task_id": "{{trigger.task_id}}", "fields": {"status": "done"}})) == set() + + +def test_a_task_node_without_a_target_fails_at_publish(): + assert "missing_config" in _codes(_graph({"fields": {"status": "done"}})) + + +def test_a_task_node_that_sets_nothing_fails_at_publish(): + assert "missing_config" in _codes(_graph({"task_id": "abc", "fields": {}})) + assert "missing_config" in _codes(_graph({"task_id": "abc"})) + + +def test_an_unknown_field_fails_at_publish_not_at_run_time(db): + """The gate's whole purpose: the maker learns while looking at the canvas, + not silently at 3am against a field that does not exist.""" + codes = _codes(_graph({"task_id": "abc", "fields": {"assignee": "x"}})) + assert "unknown_field" in codes + + +def test_a_task_node_does_NOT_need_an_approval_ancestor(): + """Pinned deliberately, because the default reading of "write" would gate it. + + The `write_without_approval` gate exists for OUTWARD writes through the + Integration Registry. A task moving to Done is internal, and nobody wants + an approval step on it — so this must stay un-gated on purpose rather than + by accident. + """ + assert "write_without_approval" not in _codes( + _graph({"task_id": "abc", "fields": {"status": "done"}}) + ) + + +# ── The node handler ──────────────────────────────────────────────────────── + +def _services(**over) -> NodeServices: + async def _unused(*a, **k): # pragma: no cover - never called + raise AssertionError("wrong seam") + + return NodeServices( + run_agent=_unused, run_tool=_unused, get_module_code=_unused, + actor="workflow:test", **over, + ) + + +def test_the_handler_resolves_refs_before_calling_the_seam(): + seen: dict = {} + + async def _update(task_id: str, fields: dict) -> dict: + seen["task_id"] = task_id + seen["fields"] = fields + return {"changed": ["status"]} + + out = run(execute_node( + { + "type": "pm_task", + "config": {"task_id": "{{trigger.task_id}}", "fields": {"status": "{{trigger.lane}}"}}, + }, + {"trigger": {"task_id": "T-1", "lane": "Shipped"}}, + _services(update_task=_update), + )) + assert seen == {"task_id": "T-1", "fields": {"status": "Shipped"}} + assert out == {"changed": ["status"]} + + +def test_the_handler_fails_loudly_when_projects_is_not_wired(): + with pytest.raises(NodeExecutionError): + run(execute_node( + {"type": "pm_task", "config": {"task_id": "T", "fields": {"status": "done"}}}, + {}, + _services(), + )) + + +def test_a_ref_that_resolves_to_nothing_fails_the_node_rather_than_patching_nothing(): + async def _update(task_id: str, fields: dict) -> dict: # pragma: no cover + raise AssertionError("should not reach the seam") + + with pytest.raises(NodeExecutionError): + run(execute_node( + {"type": "pm_task", "config": {"task_id": "{{trigger.missing}}", "fields": {"status": "x"}}}, + {"trigger": {}}, + _services(update_task=_update), + )) + + +# ── U7 — assignment is dispatch ───────────────────────────────────────────── + +def test_agent_targets_selects_agents_and_ignores_people(): + assert agent_dispatch.agent_targets( + ["priya@fracktal.in", "agent:Researcher", "agent:researcher", "sam@x.com"] + ) == ["researcher"] + + +def test_agent_targets_ignores_a_bare_prefix(): + assert agent_dispatch.agent_targets(["agent:", "agent: "]) == [] + + +def test_agent_targets_tolerates_a_payload_that_is_not_a_list(): + # An event payload is not a database row; it should not be trusted to have + # been through the API's normalisation. + assert agent_dispatch.agent_targets(None) == [] + assert agent_dispatch.agent_targets("agent:x") == [] + + +def test_the_message_names_the_task_and_carries_its_id(): + message = agent_dispatch.build_message( + SimpleNamespace(id="T-9", task_number=42, title="Fix the thing", description="Details") + ) + assert "#42" in message + assert "Fix the thing" in message + assert "Details" in message + assert "T-9" in message + + +def test_a_task_with_no_description_still_produces_a_message(): + message = agent_dispatch.build_message( + SimpleNamespace(id="T-9", task_number=None, title="Bare", description=None) + ) + assert "Bare" in message and "T-9" in message + + +def test_the_sink_ignores_events_from_other_sources_and_types(monkeypatch, db): + bind_db(monkeypatch, db, (agent_dispatch,)) + calls: list = [] + monkeypatch.setattr( + agent_dispatch, "_run_and_record", + lambda *a: calls.append(a) or _noop(), + ) + run(agent_dispatch.on_event("clickup", "pm.task.assigned", {"assignees": ["agent:x"]})) + run(agent_dispatch.on_event("projects", "pm.task.updated", {"assignees": ["agent:x"]})) + assert calls == [] + + +def test_assigning_a_person_dispatches_nothing(monkeypatch, db, project): + bind_db(monkeypatch, db, (agent_dispatch,)) + calls: list = [] + monkeypatch.setattr( + agent_dispatch, "_run_and_record", + lambda *a: calls.append(a) or _noop(), + ) + run(agent_dispatch.on_event( + "projects", "pm.task.assigned", + {"task_id": project.task.id, "assignees": ["priya@fracktal.in"]}, + )) + assert calls == [] + assert db.activities("agent_run") == [] + + +def test_assigning_an_agent_records_the_handoff_BEFORE_the_run(monkeypatch, db, project): + """Paca's `agent.session.started` move. + + A handoff that is invisible until the agent finishes looks, for its whole + duration, exactly like a handoff that never happened. So the activity is + asserted to exist at the moment dispatch is invoked, not afterwards. + """ + bind_db(monkeypatch, db, (agent_dispatch,)) + seen_at_dispatch: list[int] = [] + + async def _fake_run(agent: str, message: str, task_id: str) -> None: + seen_at_dispatch.append(len(db.activities("agent_run"))) + + monkeypatch.setattr(agent_dispatch, "_run_and_record", _fake_run) + run(agent_dispatch.on_event( + "projects", "pm.task.assigned", + {"task_id": project.task.id, "assignees": ["agent:researcher"]}, + )) + assert seen_at_dispatch == [1] + activity = db.activities("agent_run")[0] + assert activity["created_by"] == "agent:researcher" + + +def test_a_missing_task_dispatches_nothing(monkeypatch, db): + bind_db(monkeypatch, db, (agent_dispatch,)) + calls: list = [] + monkeypatch.setattr( + agent_dispatch, "_run_and_record", + lambda *a: calls.append(a) or _noop(), + ) + run(agent_dispatch.on_event( + "projects", "pm.task.assigned", + {"task_id": "00000000-0000-0000-0000-000000000000", + "assignees": ["agent:researcher"]}, + )) + assert calls == [] + + +def test_two_agents_on_one_assignment_each_get_a_run(monkeypatch, db, project): + bind_db(monkeypatch, db, (agent_dispatch,)) + dispatched: list[str] = [] + + async def _fake_run(agent: str, message: str, task_id: str) -> None: + dispatched.append(agent) + + monkeypatch.setattr(agent_dispatch, "_run_and_record", _fake_run) + run(agent_dispatch.on_event( + "projects", "pm.task.assigned", + {"task_id": project.task.id, "assignees": ["agent:a", "agent:b"]}, + )) + assert dispatched == ["a", "b"] + + +async def _noop() -> None: + return None diff --git a/tests/unit/test_projects_personal.py b/tests/unit/test_projects_personal.py new file mode 100644 index 000000000..0ae6fb5cd --- /dev/null +++ b/tests/unit/test_projects_personal.py @@ -0,0 +1,527 @@ +"""Projects · the personal lens — one store, seen as my own work. + +Spec: ``ai-company-brain/specs/project_management_app.md`` §3.11-§3.12, §6.1 · +**D-PM-6 (revised 2026-08-06)** · ticket WS-27e. + +The claims worth testing here are the *cohesion* ones, because they are what the +one-store design bought and what a mirror could never have given: + +* assigning a task makes it appear in the assignee's inbox with **no sync**; +* completing it from the inbox completes it for the **project**, same row; +* the overlay is per-member, so two assignees hold different dispositions; +* a member's triage can never move the team's board, and the team's board can + never overwrite a member's triage. + +Hermetic: no Postgres, no network. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fastapi import HTTPException +from gateway.routes.projects import core as pm_core +from gateway.routes.projects import personal as pm_personal +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_personal) + +ALICE = member_user("alice@fracktal.in") +BOB = member_user("bob@fracktal.in") +OWNER = projects_user() + + +@pytest.fixture +def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB: + fake = FakeProjectsDB() + bind_db(monkeypatch, fake, MODULES) + silence_events(monkeypatch, MODULES) + return fake + + +def _team_project(db: FakeProjectsDB) -> tuple: + project = db.seed_project(name="Sales", subject="org") + todo = db.seed_status(project.id, name="To do", category="todo", is_default=True) + done = db.seed_status( + project.id, name="Done", category="done", is_default=False, position=40, + ) + return project, todo, done + + +def _assign(db: FakeProjectsDB, task_id: str, *emails: str) -> None: + for email in emails: + db.seed( + "pm_task_assignees", task_id=task_id, assignee=email, + assigned_by="owner@fracktal.in", + ) + + +# ── The cohesion claims ───────────────────────────────────────────────────── + +async def test_assigning_a_task_puts_it_in_the_inbox_with_no_sync( + db: FakeProjectsDB, +) -> None: + """The whole point of D-PM-6's revision. + + A task assigned to Alice is not copied anywhere — it IS the row she sees. So + the inbox finds it with nothing in between, and there is no mirror table for + it to be missing from. + """ + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id, title="Ship the thing") + _assign(db, task.id, "alice@fracktal.in") + + inbox = await pm_personal.my_inbox(user=ALICE, page=page()) + + assert [row["id"] for row in inbox.rows] == [str(task.id)] + # …and no second store was consulted or written. + assert "gtd_items" not in " ".join(db.statements) + + +async def test_the_inbox_row_IS_the_project_row(db: FakeProjectsDB) -> None: + """Same id, same title, same status — because it is one row. + + A mirror would have produced a different id here, which is exactly the + property that made every downstream feature have to know about both. + """ + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id, title="One row") + _assign(db, task.id, "alice@fracktal.in") + + inbox = await pm_personal.my_inbox(user=ALICE, page=page()) + row = inbox.rows[0] + + assert row["id"] == str(task.id) + assert row["project_id"] == str(project.id) + assert row["title"] == "One row" + + +async def test_completing_from_the_inbox_completes_it_for_the_project( + db: FakeProjectsDB, +) -> None: + """The cohesion that a personal-only "done" would have destroyed: a member + ticking something off while the board still shows it open is the exact drift + a mirror produces.""" + project, todo, done = _team_project(db) + task = db.seed_task(project.id, todo.id) + _assign(db, task.id, "alice@fracktal.in") + + result = await pm_personal.complete_task(str(task.id), user=ALICE) + + assert result["status_id"] == str(done.id) + assert result["completed_at"] is not None + # The shared row moved, and the timeline records it as a real transition. + assert db.rows("pm_tasks")[0]["status_id"] == str(done.id) + assert len(db.activities("status_change")) == 1 + + +async def test_two_assignees_hold_different_dispositions( + db: FakeProjectsDB, +) -> None: + """The reason the overlay is per-member and not a column on the task. + + Alice is doing it (NEXT); Bob delegated it and is waiting (WAITING). A single + column could not express that, and it is what delegation looks like. + """ + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id) + _assign(db, task.id, "alice@fracktal.in", "bob@fracktal.in") + + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="NEXT"), user=ALICE, + ) + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="WAITING"), user=BOB, + ) + + alice_inbox = await pm_personal.my_inbox(user=ALICE, page=page()) + bob_inbox = await pm_personal.my_inbox(user=BOB, page=page()) + + assert alice_inbox.rows[0]["disposition"] == "NEXT" + assert bob_inbox.rows[0]["disposition"] == "WAITING" + assert len(db.rows("pm_task_personal")) == 2 + + +async def test_my_triage_cannot_move_the_teams_board(db: FakeProjectsDB) -> None: + """The structural half of "the overlay is never clobbered", in the direction + people forget: filing something SOMEDAY is a statement about my attention, + not about the work.""" + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id) + _assign(db, task.id, "alice@fracktal.in") + + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="SOMEDAY"), user=ALICE, + ) + + assert db.rows("pm_tasks")[0]["status_id"] == str(todo.id) + # The overlay route never writes to the task table at all. + assert not [ + s for s, _ in db.calls + if s.upper().startswith("UPDATE PM_TASKS") + ] + + +async def test_a_status_change_does_not_overwrite_my_stated_disposition( + db: FakeProjectsDB, +) -> None: + """The other direction of the same contract, and the one migration 48's + ClickUp sync has always honoured.""" + project, todo, _ = _team_project(db) + doing = db.seed_status( + project.id, name="Doing", category="in_progress", is_default=False, + ) + task = db.seed_task(project.id, todo.id) + _assign(db, task.id, "alice@fracktal.in") + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="SOMEDAY"), user=ALICE, + ) + + await pm_tasks.patch_task( + str(task.id), pm_tasks.TaskIn(status_id=str(doing.id)), user=OWNER, + ) + + inbox = await pm_personal.my_inbox(user=ALICE, include_done=True, page=page()) + assert inbox.rows[0]["disposition"] == "SOMEDAY" + + +# ── The derived disposition ───────────────────────────────────────────────── + +@pytest.mark.parametrize( + "category,is_mine,has_assignee,expected", + [ + ("done", True, True, "DONE"), + ("cancelled", True, True, "DONE"), + ("backlog", True, True, "SOMEDAY"), + ("todo", True, True, "NEXT"), + ("in_progress", False, True, "WAITING"), + ("todo", False, False, "INBOX"), + ], +) +def test_the_derived_lens_matches_the_shipped_clickup_one( + category: str, is_mine: bool, has_assignee: bool, expected: str, +) -> None: + """Lifted from `routes/tasks/sync.py` so both halves of the app agree about + what an untriaged task means.""" + assert pm_personal.derive_disposition( + status_category=category, is_mine=is_mine, has_assignee=has_assignee, + ) == expected + + +async def test_an_untriaged_task_is_distinguishable_from_a_triaged_one( + db: FakeProjectsDB, +) -> None: + """`is_triaged` is the Weekly Review's whole question — which tasks have I + not looked at. Defaulting `disposition` to INBOX in the column would have + made "never seen" and "deliberately filed to the inbox" the same state.""" + project, todo, _ = _team_project(db) + untouched = db.seed_task(project.id, todo.id, title="Never seen") + filed = db.seed_task(project.id, todo.id, title="Filed to inbox") + _assign(db, untouched.id, "alice@fracktal.in") + _assign(db, filed.id, "alice@fracktal.in") + await pm_personal.set_personal( + str(filed.id), pm_personal.PersonalIn(disposition="INBOX"), user=ALICE, + ) + + inbox = await pm_personal.my_inbox(user=ALICE, page=page()) + by_title = {r["title"]: r for r in inbox.rows} + + # The untriaged one DERIVES its disposition from the task — assigned to me + # in a todo lane, so NEXT. The filed one keeps the member's own answer even + # though the derivation would have said otherwise. + assert by_title["Never seen"]["disposition"] == "NEXT" + assert by_title["Filed to inbox"]["disposition"] == "INBOX" + # And `is_triaged` is what the Weekly Review reads: a stated INBOX and a + # never-looked-at task must not be the same state. + assert by_title["Never seen"]["is_triaged"] is False + assert by_title["Filed to inbox"]["is_triaged"] is True + + +async def test_filtering_by_disposition_matches_the_effective_one( + db: FakeProjectsDB, +) -> None: + """"Show me my next actions" must answer the same whether or not the member + has been through their inbox — filtering on the stored column alone would + show an empty Next list to somebody with twenty assigned tasks.""" + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id, title="Untriaged but mine") + _assign(db, task.id, "alice@fracktal.in") + + inbox = await pm_personal.my_inbox(user=ALICE, disposition="NEXT", page=page()) + + assert [r["title"] for r in inbox.rows] == ["Untriaged but mine"] + assert db.rows("pm_task_personal") == [] # nothing was written to read it + + +async def test_an_unknown_disposition_is_422(db: FakeProjectsDB) -> None: + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id) + + with pytest.raises(HTTPException) as exc: + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="LATER"), user=ALICE, + ) + assert exc.value.status_code == 422 + + +# ── The personal project ──────────────────────────────────────────────────── + +async def test_capture_creates_the_personal_project_once( + db: FakeProjectsDB, +) -> None: + """GTD's first discipline is frictionless capture: a title, and nothing else + required — no project to choose, no status to pick.""" + first = await pm_personal.capture( + pm_personal.CaptureIn(title="Call the supplier"), user=ALICE, + ) + await pm_personal.capture(pm_personal.CaptureIn(title="Second thought"), user=ALICE) + + personal = [p for p in db.rows("pm_projects") if p.get("personal_owner")] + assert len(personal) == 1 + assert personal[0]["personal_owner"] == "alice@fracktal.in" + assert first["title"] == "Call the supplier" + assert len(db.rows("pm_tasks")) == 2 + + +async def test_a_captured_task_is_an_ordinary_task(db: FakeProjectsDB) -> None: + """Which is what lets a captured thought later move into a team project + without being recreated — and what makes the board, timeline, automation and + agent dispatch work on it with no special case.""" + created = await pm_personal.capture( + pm_personal.CaptureIn(title="A thought"), user=ALICE, + ) + + row = db.rows("pm_tasks")[0] + assert row["source"] == "manual" + assert row["task_number"] == 1 + assert str(row["status_id"]) + # And it is assigned to the capturer, so it reaches their own inbox. + assert db.rows("pm_task_assignees")[0]["assignee"] == "alice@fracktal.in" + assert created["id"] == str(row["id"]) + + +async def test_a_personal_project_gets_its_own_statuses(db: FakeProjectsDB) -> None: + await pm_personal.create_my_project(user=ALICE) + + lanes = {s["name"]: s["category"] for s in db.rows("pm_task_statuses")} + assert lanes == { + "Inbox": "backlog", "Next": "todo", "Doing": "in_progress", "Done": "done", + } + + +async def test_a_personal_project_is_granted_to_its_owner_only( + db: FakeProjectsDB, +) -> None: + """`personal_owner` is the fast path to finding it; the GRANT is what the + visibility model actually reads. Both, so neither is load-bearing alone.""" + await pm_personal.create_my_project(user=ALICE) + + grants = db.rows("pm_project_grants") + assert [g["subject"] for g in grants] == ["alice@fracktal.in"] + + +async def test_personal_projects_stay_out_of_the_team_tree( + db: FakeProjectsDB, +) -> None: + """"My tasks" does not belong in a department tree beside Sales. This is not + a security filter — the grant already scopes it to one person — it is that a + personal project is not a department.""" + _team_project(db) + await pm_personal.create_my_project(user=ALICE) + + tree = await pm_tree.get_tree(user=OWNER) + + assert [row["name"] for row in tree["rows"]] == ["Sales"] + + +async def test_my_own_captured_task_survives_unassigning_myself( + db: FakeProjectsDB, +) -> None: + """The second arm of the inbox query. Without it, clearing your own name off + a private todo would make it vanish from the only place it exists.""" + await pm_personal.capture(pm_personal.CaptureIn(title="Mine alone"), user=ALICE) + db.tables["pm_task_assignees"] = [] + + inbox = await pm_personal.my_inbox(user=ALICE, page=page()) + + assert [r["title"] for r in inbox.rows] == ["Mine alone"] + + +# ── The tickler ───────────────────────────────────────────────────────────── + +async def test_deferring_hides_a_task_from_my_inbox_only( + db: FakeProjectsDB, +) -> None: + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id, title="Later") + _assign(db, task.id, "alice@fracktal.in", "bob@fracktal.in") + + await pm_personal.defer_task( + str(task.id), pm_personal.DeferIn(until="2099-01-01T00:00:00+00:00"), + user=ALICE, + ) + + assert (await pm_personal.my_inbox(user=ALICE, page=page())).rows == [] + # Bob's view is untouched, and so is the board. + assert len((await pm_personal.my_inbox(user=BOB, page=page())).rows) == 1 + assert db.rows("pm_tasks")[0]["status_id"] == str(todo.id) + + +async def test_include_deferred_brings_them_back(db: FakeProjectsDB) -> None: + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id) + _assign(db, task.id, "alice@fracktal.in") + await pm_personal.defer_task( + str(task.id), pm_personal.DeferIn(until="2099-01-01T00:00:00+00:00"), + user=ALICE, + ) + + inbox = await pm_personal.my_inbox( + user=ALICE, include_deferred=True, include_done=True, page=page(), + ) + assert len(inbox.rows) == 1 + + +# ── Identity ──────────────────────────────────────────────────────────────── + +def test_no_personal_route_can_be_pointed_at_another_member() -> None: + """R3/R4, made structural. There is deliberately no `?member=` anywhere + here, so no request can read or write somebody else's practice.""" + import inspect + + for name in ( + "my_inbox", "my_contexts", "set_personal", "capture", + "get_my_project", "create_my_project", "defer_task", "complete_task", + ): + params = set(inspect.signature(getattr(pm_personal, name)).parameters) + assert "user" in params, name + assert not (params & {"member", "member_email", "email", "who", "user_id"}), name + + +async def test_the_overlay_write_is_scoped_to_the_caller( + db: FakeProjectsDB, +) -> None: + """Structural: the upsert binds the caller's own address, so there is no + shape of request that writes another member's row.""" + project, todo, _ = _team_project(db) + task = db.seed_task(project.id, todo.id) + + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(context="@calls"), user=ALICE, + ) + + statement, params = next( + (s, p) for s, p in db.calls if "pm_task_personal" in s and s.startswith("INSERT") + ) + assert params["member_email"] == "alice@fracktal.in" + assert "member_email" in statement + + +async def test_seeing_the_task_is_the_floor_for_triaging_it( + db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A task nobody granted you and nobody assigned you is 404, so the overlay + cannot be used to probe for tasks that exist.""" + real = pm_core.resolve_visibility + + async def _resolve(db_, user): + vis = await real(db_, user) + return pm_core.Visibility(unrestricted=False, email=vis.email, groups=()) + + for module in MODULES: + monkeypatch.setattr(module, "resolve_visibility", _resolve, raising=False) + + project = db.seed_project(name="Finance", subject=None) + todo = db.seed_status(project.id) + task = db.seed_task(project.id, todo.id) + + with pytest.raises(HTTPException) as exc: + await pm_personal.set_personal( + str(task.id), pm_personal.PersonalIn(disposition="NEXT"), user=ALICE, + ) + assert exc.value.status_code == 404 + + +# ── The migration ─────────────────────────────────────────────────────────── + +MIGRATIONS = Path(__file__).resolve().parents[2] / "infra" / "postgres" + + +def _personal_migration() -> Path: + """Found by CONTENT, not by number (R1) — a test pinned to `147_` would be + the same mistake the 145 collision already made once.""" + found = [ + p for p in sorted(MIGRATIONS.glob("*.sql")) + if p.name != "schema.generated.sql" + and "CREATE TABLE IF NOT EXISTS pm_task_personal" in p.read_text( + encoding="utf-8", + ) + ] + assert len(found) == 1, [p.name for p in found] + return found[0] + + +@pytest.fixture(scope="module") +def sql() -> str: + return _personal_migration().read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def bare(sql: str) -> str: + return "\n".join(re.sub(r"--.*$", "", line) for line in sql.splitlines()) + + +def test_the_migration_is_idempotent(bare: str) -> None: + assert not re.search(r"CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I) + assert not re.search(r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I) + assert not re.search(r"ADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I) + + +def test_the_overlay_is_keyed_per_member(bare: str) -> None: + """The decision the whole design rests on: two assignees, two rows.""" + assert re.search( + r"PRIMARY\s+KEY\s*\(\s*task_id\s*,\s*member_email\s*\)", bare, re.I, + ) + + +def test_disposition_has_no_default(bare: str) -> None: + """NULL means "not triaged" and must stay distinguishable from INBOX.""" + block = bare.split("CREATE TABLE IF NOT EXISTS pm_task_personal", 1)[1].split(");", 1)[0] + disposition_line = next( + line for line in block.splitlines() if line.strip().startswith("disposition") + ) + assert "DEFAULT" not in disposition_line.upper() + + +def test_one_personal_project_per_member_case_insensitively(bare: str) -> None: + assert re.search( + r"CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+\w+\s+" + r"ON\s+pm_projects\s*\(\s*lower\(personal_owner\)\s*\)", + bare, re.I, + ) + assert "WHERE personal_owner IS NOT NULL" in bare + + +def test_the_disposition_vocabulary_matches_migration_48(bare: str) -> None: + """WS-27h has to move every gtd_items row onto this column; a renamed + disposition would make that migration a translation instead of a copy.""" + legacy = (MIGRATIONS / "48_task_manager_gtd.sql").read_text(encoding="utf-8") + legacy_values = set( + re.findall( + r"\b(INBOX|NEXT|WAITING|SOMEDAY|PROJECT|REFERENCE|DONE|TRASH)\b", legacy, + ) + ) + for value in legacy_values: + assert f"'{value}'" in bare, f"disposition {value} is missing" diff --git a/tests/unit/test_workflows_automation_health.py b/tests/unit/test_workflows_automation_health.py index f3facbc9d..080e6dbec 100644 --- a/tests/unit/test_workflows_automation_health.py +++ b/tests/unit/test_workflows_automation_health.py @@ -248,7 +248,9 @@ async def fake_health(wid: str, name: str, *, trigger_kind: str, status: str) -> monkeypatch.setattr(service, "execute_workflow", fake_execute_workflow) monkeypatch.setattr(service, "_finish_run", fake_finish) monkeypatch.setattr(service, "evaluate_automation_health", fake_health) - monkeypatch.setattr(service, "build_node_services", lambda actor: None) + # Two args since WS-27f: the workflow id is bound into the Projects write + # seam so a `pm_task` node writes as `system:workflow:`. + monkeypatch.setattr(service, "build_node_services", lambda actor, wid="": None) monkeypatch.setattr(service, "publish_workflow_activity", lambda *a, **k: None) asyncio.run( diff --git a/workbench/control_plane/src/app/api/people/[...path]/route.ts b/workbench/control_plane/src/app/api/people/[...path]/route.ts new file mode 100644 index 000000000..f35b58d4c --- /dev/null +++ b/workbench/control_plane/src/app/api/people/[...path]/route.ts @@ -0,0 +1,72 @@ +/** + * GET /api/people/[…path] + * + * Proxies People Center reads to the FastAPI gateway's /people/* API. The + * browser talks to the Next server, which holds the session and forwards an + * authenticated request (internal bearer + X-User-Email) upstream — the same + * shape as the Projects and CRM proxies. + * + * ⚠️ This is not a convenience layer. `/people` is gated by + * `require_feature_router("people")` and the gateway takes the acting identity + * from `X-User-Email` only, so a page that fetched the gateway directly would + * carry neither and 401. Nothing in this app may point the browser at api.* . + * + * **GET only, deliberately.** The People Center's directory is a read surface; + * its writes (create/edit a person, upload a résumé) already exist on the + * tasks API under `admin:members:manage` and go through `/api/tasks`. Adding + * write verbs here that forward to endpoints the gateway does not serve would + * mint a second, hollow write path — and the first person to find it would + * reasonably assume it worked. + */ +import { NextRequest, NextResponse } from "next/server"; +import { GATEWAY_URL, gatewayHeaders, requireIdentity } from "@/lib/gateway"; + +export const dynamic = "force-dynamic"; + +function buildUpstreamUrl(path: string[], req: NextRequest): string { + const base = `${GATEWAY_URL}/people/${path.join("/")}`; + const qs = req.nextUrl.searchParams.toString(); + return qs ? `${base}?${qs}` : base; +} + +export async function GET( + req: NextRequest, + ctx: { params: Promise<{ path: string[] }> } +) { + // Identity is resolved BEFORE forwarding. `gatewayHeaders()` throwing is what + // makes an unguarded call fail closed, but that throw lands in the catch + // below and answers 502 — telling a signed-out member the gateway is down + // when what they need is a sign-in. Pinned by `src/lib/gateway.test.ts`. + const me = await requireIdentity(); + if (me instanceof NextResponse) return me; + + const { path } = await ctx.params; + const upstream = buildUpstreamUrl(path, req); + try { + const init: RequestInit = { + method: "GET", + headers: await gatewayHeaders(), + signal: AbortSignal.timeout(30_000), + }; + // A pooled keep-alive socket can be closed by the gateway just as we reuse + // it (undici vs uvicorn's short keep-alive). Reads are idempotent, so a + // spurious network failure gets one retry. + let res: Response; + try { + res = await fetch(upstream, init); + } catch { + res = await fetch(upstream, { ...init, signal: AbortSignal.timeout(30_000) }); + } + const text = await res.text(); + if (!text) return new NextResponse(null, { status: res.status }); + return new NextResponse(text, { + status: res.status, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return NextResponse.json( + { detail: `People gateway unreachable: ${String(err)}` }, + { status: 502 } + ); + } +} diff --git a/workbench/control_plane/src/app/api/projects/[...path]/route.ts b/workbench/control_plane/src/app/api/projects/[...path]/route.ts index 332cce7c0..13b1cca0c 100644 --- a/workbench/control_plane/src/app/api/projects/[...path]/route.ts +++ b/workbench/control_plane/src/app/api/projects/[...path]/route.ts @@ -41,6 +41,8 @@ async function forward( method, headers: { ...(await gatewayHeaders()), + // Set for the JSON path; the multipart branch below overwrites it + // with the caller's own boundary-carrying type. ...(method === "GET" || method === "DELETE" ? {} : { "Content-Type": "application/json" }), @@ -48,8 +50,20 @@ async function forward( signal: AbortSignal.timeout(30_000), }; if (method !== "GET" && method !== "DELETE") { - const body = await req.json().catch(() => ({})); - init.body = JSON.stringify(body); + // A multipart upload (WS-27i attachments) must reach the gateway + // BYTE-FOR-BYTE. Re-parsing it as JSON is not merely lossy — `req.json()` + // rejects, the catch below hands back `{}`, and the request arrives with + // no file at all while still answering 201. The workflows app documents + // the same trap for HMAC-signed webhook bodies (`workflows_app.md` + // §3.3b); this is that trap on the upload path. + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("application/json") || contentType === "") { + const body = await req.json().catch(() => ({})); + init.body = JSON.stringify(body); + } else { + init.body = await req.arrayBuffer(); + (init.headers as Record)["Content-Type"] = contentType; + } } // A pooled keep-alive socket can be closed by the gateway just as we reuse // it, failing the fetch spuriously (undici vs uvicorn's short keep-alive). diff --git a/workbench/control_plane/src/app/people/components/PersonPanel.tsx b/workbench/control_plane/src/app/people/components/PersonPanel.tsx new file mode 100644 index 000000000..b0dc0fa74 --- /dev/null +++ b/workbench/control_plane/src/app/people/components/PersonPanel.tsx @@ -0,0 +1,223 @@ +"use client"; + +/** + * People Center · the person page (§3.2) — four panels. + * + * Identity, skills, capacity, work. The panels are deliberately in that order: + * who they are, what they can do, how loaded they are, what they are holding. + */ +import { X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { type PersonDetail, type WorkRow, peopleApi } from "../lib/api"; +import { initials, loadBar, skillOrigin, statusTone } from "../lib/directory"; + +interface Props { + personId: string; + onClose: () => void; +} + +const TONE: Record = { + active: "bg-muted text-foreground", + warn: "border border-border text-foreground", + muted: "text-muted-foreground", +}; + +export function PersonPanel({ personId, onClose }: Props) { + const [person, setPerson] = useState(null); + const [work, setWork] = useState<{ rows: WorkRow[]; available: boolean } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + (async () => { + try { + const [detail, tasks] = await Promise.all([ + peopleApi.person(personId), + peopleApi.work(personId), + ]); + if (!live) return; + setPerson(detail); + setWork({ rows: tasks.rows, available: tasks.available }); + setError(null); + } catch (err) { + if (live) setError(String((err as Error).message)); + } + })(); + return () => { + live = false; + }; + }, [personId]); + + if (error) { + return ( + + ); + } + if (!person) { + return ( + + ); + } + + const bar = loadBar(person); + + return ( + + ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +

+ {label} + {children} +

+ ); +} diff --git a/workbench/control_plane/src/app/people/lib/api.ts b/workbench/control_plane/src/app/people/lib/api.ts new file mode 100644 index 000000000..68cdff085 --- /dev/null +++ b/workbench/control_plane/src/app/people/lib/api.ts @@ -0,0 +1,99 @@ +/** + * People Center · the browser's client for /api/people/*. + * + * Read-only, matching the proxy. Writes live on the tasks API under + * `admin:members:manage` and are not reachable from here. + */ + +export interface PersonRow { + id: string; + name: string; + email?: string | null; + role?: string | null; + title?: string | null; + department?: string | null; + team?: string | null; + status: string; + manager_id?: string | null; + /** Null-projected without `admin:members:read` — see `hrVisible`. */ + skills?: string[]; + skills_source?: Record; + resume_summary?: string | null; + years_experience?: number | null; + capacity_hours_per_week?: number | null; + current_load_hours_per_week?: number | null; + available_hours_per_week?: number | null; +} + +export interface PersonDetail extends PersonRow { + has_login: boolean; + email_conflict?: string | null; + manager?: string | null; + hr_visible: boolean; + load?: { + open_tasks: number; + estimated_hours: number; + unestimated: number; + } | null; +} + +export interface WorkRow { + id: string; + title: string; + task_number?: number | null; + due_at?: string | null; + project_id?: string | null; + project_name?: string | null; + status_name: string; + status_category: string; +} + +export class PeopleApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = "PeopleApiError"; + } +} + +async function call(path: string): Promise { + const res = await fetch(`/api/people/${path}`); + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + if (!res.ok) { + throw new PeopleApiError( + body?.detail ?? `Request failed (${res.status})`, + res.status + ); + } + return body as T; +} + +export const peopleApi = { + directory: (params: Record = {}) => { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "" && value !== false) { + qs.set(key, String(value)); + } + } + const query = qs.toString(); + return call<{ rows: PersonRow[]; total: number; hr_visible: boolean }>( + `${query ? `?${query}` : ""}` + ); + }, + + facets: () => + call<{ + departments: Array<{ department: string; total: number }>; + teams: Array<{ department: string; team: string; total: number }>; + statuses: string[]; + }>("facets"), + + person: (id: string) => call(id), + + work: (id: string) => + call<{ rows: WorkRow[]; total: number; available: boolean }>(`${id}/work`), +}; diff --git a/workbench/control_plane/src/app/people/lib/directory.test.ts b/workbench/control_plane/src/app/people/lib/directory.test.ts new file mode 100644 index 000000000..7f3a6d8a9 --- /dev/null +++ b/workbench/control_plane/src/app/people/lib/directory.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; + +import type { PersonRow } from "./api"; +import { + NO_DEPARTMENT, + SKILLS_SHOWN, + groupByDepartment, + initials, + loadBar, + skillOrigin, + skillsState, + statusTone, +} from "./directory"; + +function person(over: Partial & { id: string }): PersonRow { + return { name: `p${over.id}`, status: "active", ...over }; +} + +describe("initials", () => { + it("takes the first and last token", () => { + // Not every token: a three-letter monogram in a 24px circle is a smudge. + expect(initials("Vijay Raghav Varada")).toBe("VV"); + }); + + it("handles a single name", () => { + expect(initials("Priya")).toBe("P"); + }); + + it("survives an empty name rather than throwing", () => { + expect(initials(" ")).toBe("?"); + }); +}); + +describe("skillsState", () => { + it("says RESTRICTED when the HR half was projected away", () => { + // The distinction §3.1 insists on — a blank strip cannot say which of the + // two facts it means, so the state must. + expect(skillsState([], false)).toEqual({ kind: "restricted" }); + expect(skillsState(["Firmware"], false)).toEqual({ kind: "restricted" }); + }); + + it("says EMPTY only when the caller could have seen skills", () => { + expect(skillsState([], true)).toEqual({ kind: "empty" }); + expect(skillsState(undefined, true)).toEqual({ kind: "empty" }); + }); + + it("shows the first few and counts the rest", () => { + const state = skillsState(["a", "b", "c", "d", "e"], true); + expect(state).toEqual({ kind: "some", shown: ["a", "b", "c"], more: 2 }); + expect((state as { shown: string[] }).shown).toHaveLength(SKILLS_SHOWN); + }); + + it("does not report more when everything fits", () => { + expect(skillsState(["a", "b"], true)).toEqual({ + kind: "some", shown: ["a", "b"], more: 0, + }); + }); + + it("ignores blank entries rather than rendering empty chips", () => { + expect(skillsState(["a", " ", ""], true)).toEqual({ + kind: "some", shown: ["a"], more: 0, + }); + }); +}); + +describe("skillOrigin", () => { + it("reads a stated skill as stated", () => { + expect(skillOrigin("Firmware", { Firmware: "stated" })).toBe("stated"); + }); + + it("defaults UNKNOWN provenance to resume, not stated", () => { + // Over-claiming on somebody's behalf is the worse error: a skill nobody + // stated and a parser inferred must not look like a claim they made. + expect(skillOrigin("Firmware", {})).toBe("resume"); + expect(skillOrigin("Firmware", undefined)).toBe("resume"); + }); +}); + +describe("loadBar", () => { + it("is unknown when there is no capacity and no work", () => { + expect(loadBar({ capacity_hours_per_week: null, load: null }).unknown).toBe(true); + }); + + it("computes a percentage from estimated hours against capacity", () => { + const bar = loadBar({ + capacity_hours_per_week: 40, + load: { open_tasks: 4, estimated_hours: 20, unestimated: 0 }, + }); + expect(bar.percent).toBe(50); + expect(bar.unknown).toBe(false); + expect(bar.label).toContain("20h of 40h"); + }); + + it("clamps an over-committed person at 100 rather than overflowing the bar", () => { + const bar = loadBar({ + capacity_hours_per_week: 10, + load: { open_tasks: 9, estimated_hours: 80, unestimated: 0 }, + }); + expect(bar.percent).toBe(100); + }); + + it("refuses to draw a confident zero when NOTHING is estimated", () => { + // The load-bearing case: thirty un-estimated tasks contribute zero hours, + // and a 0% bar would read as "completely free". + const bar = loadBar({ + capacity_hours_per_week: 40, + load: { open_tasks: 30, estimated_hours: 0, unestimated: 30 }, + }); + expect(bar.unknown).toBe(true); + expect(bar.percent).toBe(0); + expect(bar.label).toContain("none estimated"); + }); + + it("names the un-estimated remainder alongside a real number", () => { + const bar = loadBar({ + capacity_hours_per_week: 40, + load: { open_tasks: 5, estimated_hours: 10, unestimated: 2 }, + }); + expect(bar.unknown).toBe(false); + expect(bar.label).toContain("2 with no estimate"); + }); + + it("is unknown when capacity is unset even though work exists", () => { + const bar = loadBar({ + capacity_hours_per_week: null, + load: { open_tasks: 3, estimated_hours: 6, unestimated: 0 }, + }); + expect(bar.unknown).toBe(true); + expect(bar.label).toContain("3 open"); + }); +}); + +describe("groupByDepartment", () => { + it("sorts named departments and trails the unassigned", () => { + const groups = groupByDepartment([ + person({ id: "1", department: "Sales" }), + person({ id: "2" }), + person({ id: "3", department: "Engineering" }), + ]); + expect(groups.map((g) => g.department)).toEqual([ + "Engineering", "Sales", NO_DEPARTMENT, + ]); + }); + + it("never drops a person with no department", () => { + // A directory that quietly omits somebody is worse than an untidy last + // section. + const groups = groupByDepartment([person({ id: "1", department: " " })]); + expect(groups).toHaveLength(1); + expect(groups[0].department).toBe(NO_DEPARTMENT); + expect(groups[0].people).toHaveLength(1); + }); + + it("omits the unassigned group entirely when everyone has a department", () => { + const groups = groupByDepartment([person({ id: "1", department: "Sales" })]); + expect(groups.map((g) => g.department)).toEqual(["Sales"]); + }); + + it("keeps every person exactly once", () => { + const rows = [ + person({ id: "1", department: "Sales" }), + person({ id: "2", department: "Sales" }), + person({ id: "3", department: "Ops" }), + ]; + const total = groupByDepartment(rows).reduce((n, g) => n + g.people.length, 0); + expect(total).toBe(rows.length); + }); +}); + +describe("statusTone", () => { + it("separates the three vocabularies migration 148 allows", () => { + expect(statusTone("active")).toBe("active"); + expect(statusTone("contractor")).toBe("warn"); + expect(statusTone("invited")).toBe("warn"); + expect(statusTone("alumni")).toBe("muted"); + }); + + it("falls back to muted for anything unexpected", () => { + // Migration 148's CHECK is NOT VALID, so a legacy row can still carry an + // unknown status; it must render, not crash. + expect(statusTone("weird")).toBe("muted"); + }); +}); diff --git a/workbench/control_plane/src/app/people/lib/directory.ts b/workbench/control_plane/src/app/people/lib/directory.ts new file mode 100644 index 000000000..6eabf7bf8 --- /dev/null +++ b/workbench/control_plane/src/app/people/lib/directory.ts @@ -0,0 +1,162 @@ +/** + * People Center · presentation logic for the directory and person page. + * + * Spec: `ai-company-brain/specs/people_center_app.md` §3.1, §3.2, §5.2. + * + * Pure functions only. The server already decided which rows come back and + * whether the HR half is filled in; this decides how they read. Keeping the + * projection server-side and the presentation here is what stops the browser + * growing a second, weaker answer to "may this person see skills". + */ + +import type { PersonDetail, PersonRow } from "./api"; + +/** How many skills a directory row shows before collapsing into "+N". */ +export const SKILLS_SHOWN = 3; + +/** + * The initials for an avatar fallback. + * + * First and last token, so "Vijay Raghav Varada" reads VV rather than VRV — + * a three-letter monogram in a 24px circle is a smudge. + */ +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + const first = parts[0][0] ?? ""; + const last = parts.length > 1 ? parts[parts.length - 1][0] ?? "" : ""; + return (first + last).toUpperCase(); +} + +export type SkillsState = + | { kind: "restricted" } + | { kind: "empty" } + | { kind: "some"; shown: string[]; more: number }; + +/** + * What the skills strip should render — and the distinction §3.1 insists on. + * + * "You may not see this" and "nobody filled it in" are different facts, and a + * blank strip cannot tell them apart. `hrVisible` comes from the server's own + * statement about what it projected, never from guessing at a null. + */ +export function skillsState( + skills: readonly string[] | undefined, + hrVisible: boolean +): SkillsState { + if (!hrVisible) return { kind: "restricted" }; + const list = (skills ?? []).filter((s) => (s ?? "").trim().length > 0); + if (list.length === 0) return { kind: "empty" }; + return { + kind: "some", + shown: list.slice(0, SKILLS_SHOWN), + more: Math.max(0, list.length - SKILLS_SHOWN), + }; +} + +/** + * Where a skill came from: typed by a human, or extracted from a résumé. + * + * A skill nobody stated and a parser inferred should not look like a claim the + * person made (§3.2 panel 2). Unknown provenance reads as `resume` rather than + * `stated`, because over-claiming on someone's behalf is the worse error. + */ +export function skillOrigin( + skill: string, + source: Record | undefined +): "stated" | "resume" { + return (source ?? {})[skill] === "stated" ? "stated" : "resume"; +} + +export interface LoadBar { + /** 0-100, clamped. */ + percent: number; + label: string; + /** True when the bar is built on nothing and should be drawn as unknown. */ + unknown: boolean; +} + +/** + * The capacity bar (§3.2 panel 3, §5.2). + * + * One bar, not three numbers. Load is the **computed** figure — hours from open + * assigned tasks — not `current_load_hours_per_week`, which is a number + * somebody typed once and which is stale the moment anyone assigns anything. + * + * The honest part is `unestimated`. A task with no estimate contributes zero + * hours, so a bar built only from the sum would show somebody holding thirty + * un-estimated tasks as completely free. When nothing is estimated the bar + * refuses to draw a percentage at all rather than drawing a confident zero. + */ +export function loadBar(person: Pick): LoadBar { + const capacity = person.capacity_hours_per_week ?? 0; + const load = person.load; + if (!load || (load.open_tasks === 0 && capacity <= 0)) { + return { percent: 0, label: "No capacity data", unknown: true }; + } + const hours = load.estimated_hours ?? 0; + const tail = + load.unestimated > 0 ? ` · ${load.unestimated} with no estimate` : ""; + if (capacity <= 0) { + return { + percent: 0, + label: `${load.open_tasks} open${tail}`, + unknown: true, + }; + } + if (hours === 0 && load.unestimated > 0) { + // Everything they hold is un-estimated: a 0% bar would read as "free". + return { + percent: 0, + label: `${load.open_tasks} open, none estimated`, + unknown: true, + }; + } + return { + percent: Math.min(100, Math.round((hours / capacity) * 100)), + label: `${hours}h of ${capacity}h · ${load.open_tasks} open${tail}`, + unknown: false, + }; +} + +/** + * Group directory rows by department for the list's section headers. + * + * People with no department land in one trailing group rather than being + * dropped — a directory that quietly omits somebody is worse than one with an + * untidy last section. + */ +export const NO_DEPARTMENT = "Unassigned"; + +export function groupByDepartment( + rows: readonly PersonRow[] +): Array<{ department: string; people: PersonRow[] }> { + const groups = new Map(); + for (const row of rows) { + const key = (row.department ?? "").trim() || NO_DEPARTMENT; + const bucket = groups.get(key); + if (bucket) bucket.push(row); + else groups.set(key, [row]); + } + const named = [...groups.entries()] + .filter(([d]) => d !== NO_DEPARTMENT) + .sort((a, b) => a[0].localeCompare(b[0])); + const rest = groups.get(NO_DEPARTMENT); + return [ + ...named.map(([department, people]) => ({ department, people })), + ...(rest ? [{ department: NO_DEPARTMENT, people: rest }] : []), + ]; +} + +/** The pill styling class for a status, so the vocabulary lives in one place. */ +export function statusTone(status: string): "active" | "muted" | "warn" { + switch (status) { + case "active": + return "active"; + case "contractor": + case "invited": + return "warn"; + default: + return "muted"; + } +} diff --git a/workbench/control_plane/src/app/people/lib/registration.test.ts b/workbench/control_plane/src/app/people/lib/registration.test.ts new file mode 100644 index 000000000..ccaf77ec4 --- /dev/null +++ b/workbench/control_plane/src/app/people/lib/registration.test.ts @@ -0,0 +1,76 @@ +/** + * People · the app is registered in every place that must know about it. + * + * Spec: `ai-company-brain/specs/people_center_app.md` §6 · ticket WS-28b. + * + * The failure this exists to catch is the one migration 140 shipped for real: + * a surface seeded in one place and missing from another is **unreachable, + * silently, for everybody including the owner** — and every other test still + * passes, because nothing else reads the registry that was skipped. + * + * The Python half (`FEATURES` ↔ `feature_catalog`, and the router's own gate) + * is fenced in `tests/unit/test_org_access_control.py` and + * `tests/unit/test_people_directory.py`. This is the frontend half. + */ +import { describe, expect, it } from "vitest"; + +import { CENTERS } from "@/lib/centers"; +import { NAV_SECTIONS } from "@/lib/nav"; +import { featureForPath } from "@/lib/access"; + +const PEOPLE_HREF = "/people"; +const PEOPLE_FEATURE = "people"; + +describe("the People app is reachable", () => { + it("has a nav pane gated on its own feature", () => { + const items = NAV_SECTIONS.flatMap((s) => s.items); + const pane = items.find((i) => i.href === PEOPLE_HREF); + + expect(pane, "/people has no nav entry — the pane would never render").toBeTruthy(); + expect(pane?.feature).toBe(PEOPLE_FEATURE); + }); + + it("maps its route to the same feature slug", () => { + // A pane gated on one slug and a route guarded by another is how a member + // sees a link that then refuses them. + expect(featureForPath(PEOPLE_HREF)).toBe(PEOPLE_FEATURE); + }); + + it("does NOT ride the tasks feature", () => { + // The whole reason `people` exists as its own slug: a manager who needs the + // org chart and the assignee picker should not have to be handed the + // personal GTD task manager to get them (§6). + expect(featureForPath(PEOPLE_HREF)).not.toBe("tasks"); + }); +}); + +describe("the People Center's directory sub-app", () => { + const peopleCenter = CENTERS.find((c) => c.slug === "people"); + + it("exists", () => { + expect(peopleCenter).toBeTruthy(); + }); + + it("points its directory entry at the live app", () => { + // WS-13 asked for exactly this read view and it stayed `planned` until now. + const entry = peopleCenter?.apps.find((a) => + a.label.toLowerCase().includes("directory") + ); + expect(entry, "the People Center has no directory entry").toBeTruthy(); + expect(entry?.status).toBe("live"); + expect(entry?.href).toBe(PEOPLE_HREF); + }); + + it("links at the app's own path, never a Center-forked one", () => { + // A Center item is (app + scope). Forking the app per department is the + // bloat failure mode `department_centers.md` §1 rule 2 says to refuse in + // review — the same rule the Projects entries follow. + for (const center of CENTERS) { + for (const app of center.apps) { + if (!app.href?.startsWith(PEOPLE_HREF)) continue; + const path = app.href.split("?")[0]; + expect(path).toBe(PEOPLE_HREF); + } + } + }); +}); diff --git a/workbench/control_plane/src/app/people/page.tsx b/workbench/control_plane/src/app/people/page.tsx new file mode 100644 index 000000000..931b86b5f --- /dev/null +++ b/workbench/control_plane/src/app/people/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +/** + * People Center · the directory (§3.1) and the person page (§3.2). + * + * Spec: `ai-company-brain/specs/people_center_app.md` · ticket WS-28b. + * + * ONE app, like Projects. The People Center links here; so does the Projects + * assignee picker (WS-28e) once it lands. A person findable in one is findable + * in the other because both read the same endpoint. + * + * Writes are absent rather than disabled: without `admin:members:manage` there + * is no edit control to grey out, because disabled-button theatre teaches + * people to hunt for permissions they may never get. + */ +import { useEffect, useMemo, useState } from "react"; + +import { PersonPanel } from "./components/PersonPanel"; +import { type PersonRow, peopleApi } from "./lib/api"; +import { + groupByDepartment, + initials, + skillsState, + statusTone, +} from "./lib/directory"; + +const TONE: Record = { + active: "bg-muted text-foreground", + warn: "border border-border text-foreground", + muted: "text-muted-foreground", +}; + +export default function PeoplePage() { + const [rows, setRows] = useState([]); + const [hrVisible, setHrVisible] = useState(true); + const [facets, setFacets] = useState<{ + departments: Array<{ department: string; total: number }>; + statuses: string[]; + }>({ departments: [], statuses: [] }); + const [q, setQ] = useState(""); + const [department, setDepartment] = useState(null); + const [status, setStatus] = useState(null); + const [openId, setOpenId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + (async () => { + try { + const res = await peopleApi.directory({ + q, + department: department ?? undefined, + status: status ?? undefined, + }); + if (!live) return; + setRows(res.rows); + setHrVisible(res.hr_visible); + setError(null); + } catch (err) { + if (live) setError(String((err as Error).message)); + } finally { + if (live) setLoading(false); + } + })(); + return () => { + live = false; + }; + }, [q, department, status]); + + useEffect(() => { + let live = true; + peopleApi + .facets() + .then((res) => { + if (live) setFacets({ departments: res.departments, statuses: res.statuses }); + }) + .catch(() => { + // Filters are an accelerator, not the surface. A directory that fails + // to render because its facet query failed would be worse than one + // without filter chips. + }); + return () => { + live = false; + }; + }, []); + + const groups = useMemo(() => groupByDepartment(rows), [rows]); + + return ( +
+
+
+
+

People

+ + {loading ? "loading…" : `${rows.length} in the directory`} + +
+ setQ(e.target.value)} + placeholder={ + hrVisible + ? "Search name, title, department or skill…" + : "Search name, title or department…" + } + aria-label="Search the directory" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" + /> +
+ setDepartment(null)}> + All departments + + {facets.departments.map((d) => ( + setDepartment(d.department)} + > + {d.department} ({d.total}) + + ))} + + setStatus(null)}> + Any status + + {facets.statuses.map((s) => ( + setStatus(s)}> + {s} + + ))} +
+ {!hrVisible ? ( + // Stated once, at the top, rather than as a puzzle repeated on + // every row: a blank skills strip means "restricted" here, not + // "nobody filled it in". +

+ Skills, résumés and capacity are hidden — they need{" "} + admin:members:read. +

+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {!loading && rows.length === 0 ? ( +

Nobody matches that.

+ ) : null} + {groups.map((group) => ( +
+

+ {group.department}{" "} + ({group.people.length}) +

+ {group.people.map((p) => { + const skills = skillsState(p.skills, hrVisible); + return ( + + ); + })} +
+ ))} +
+
+ + {openId ? ( + setOpenId(null)} /> + ) : null} +
+ ); +} + +function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/workbench/control_plane/src/app/projects/components/MyWork.tsx b/workbench/control_plane/src/app/projects/components/MyWork.tsx new file mode 100644 index 000000000..904e2b047 --- /dev/null +++ b/workbench/control_plane/src/app/projects/components/MyWork.tsx @@ -0,0 +1,296 @@ +"use client"; + +/** + * Projects · My work — the personal lens over the one task store. + * + * Spec: `ai-company-brain/specs/project_management_app.md` §3.11-§3.12, §6.1 · + * **D-PM-6 (revised)** · ticket WS-27e. + * + * This is **not a second app**. The rows here are `pm_tasks` rows — the same + * ones on the team board — so ticking one off moves the project's status at the + * same instant, and a captured thought can later be dragged into a team project + * without being recreated. What is personal is the *overlay*: disposition, + * context, defer. That is why this file selects and triages but never edits + * shared fields, other than through `complete`, which is deliberately shared. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { myWorkApi, type TaskRow } from "../lib/api"; +import { + actionLine, + groupByDisposition, + isOverdue, + twoMinuteTasks, + untriagedCount, + type MyTaskRow, +} from "../lib/mywork"; + +const TRIAGE: Array<{ value: string; label: string }> = [ + { value: "NEXT", label: "Next" }, + { value: "WAITING", label: "Waiting" }, + { value: "SOMEDAY", label: "Someday" }, + { value: "REFERENCE", label: "File" }, +]; + +interface Props { + onSelect: (task: TaskRow) => void; +} + +export function MyWork({ onSelect }: Props) { + const [rows, setRows] = useState([]); + const [contexts, setContexts] = useState>([]); + const [context, setContext] = useState(null); + const [capture, setCapture] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + // Reloading is a dependency bump rather than a callback, so every fetch — + // the first one, a context switch, and the one after a mutation — runs + // through the same cancellable effect. The `live` guard is what keeps a slow + // response from a context the member has already left off the screen. + // + // The error clears on SUCCESS, not on entry: blanking it while the retry is + // in flight hides the failure at the moment it is being read. + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let live = true; + (async () => { + try { + const [inbox, ctx] = await Promise.all([ + myWorkApi.inbox(context ? { context } : {}), + myWorkApi.contexts(), + ]); + if (!live) return; + setRows(inbox.rows as MyTaskRow[]); + setContexts(ctx.rows); + setError(null); + } catch (err) { + if (live) setError(String((err as Error).message)); + } finally { + if (live) setLoading(false); + } + })(); + return () => { + live = false; + }; + }, [context, reloadKey]); + + const lanes = useMemo(() => groupByDisposition(rows), [rows]); + const untriaged = useMemo(() => untriagedCount(rows), [rows]); + const quick = useMemo(() => twoMinuteTasks(rows), [rows]); + // Read fresh on every render, deliberately: "overdue" must not be pinned to + // whenever this pane first mounted. + const now = new Date(); + + // Every mutation reloads rather than patching local state. The endpoints here + // move SHARED status (complete) and derive dispositions server-side, so a + // locally-guessed row would be a guess about two other systems. + const run = useCallback( + async (taskId: string, work: () => Promise) => { + setBusy(taskId); + try { + await work(); + setReloadKey((k) => k + 1); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(null); + } + }, + [] + ); + + async function submitCapture(event: React.FormEvent) { + event.preventDefault(); + const title = capture.trim(); + if (!title) return; + setCapture(""); + await run("capture", () => myWorkApi.capture({ title })); + } + + if (loading) { + return

Loading your work…

; + } + + return ( +
+ {/* Capture first, and above everything: GTD's one non-negotiable is that + getting a thought out of your head must cost nothing. No project to + pick, no status to choose — a title and Enter. */} +
+ setCapture(e.target.value)} + placeholder="Capture a thought…" + aria-label="Capture a task" + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + {untriaged > 0 + ? `${untriaged} not yet triaged` + : "Everything here has been triaged"} + + {contexts.length > 0 ? ( + + + {contexts.map((c) => ( + + ))} + + ) : null} +
+ +
+ {quick.length > 0 ? ( +
+

+ Under two minutes ({quick.length}) +

+

+ The one part of the method that pays off before you stand up. +

+ {quick.map((task) => ( + + ))} +
+ ) : null} + + {lanes.map((lane) => ( +
+

+ {lane.label}{" "} + ({lane.tasks.length}) +

+ {lane.tasks.length === 0 ? ( +

+ {lane.lane === "INBOX" + ? "Nothing waiting to be clarified." + : "Nothing here."} +

+ ) : ( + lane.tasks.map((task) => ( + + )) + )} +
+ ))} +
+
+ ); +} + +function Row({ + task, + now, + busy, + onSelect, + onRun, +}: { + task: MyTaskRow; + now: Date; + busy: boolean; + onSelect: (task: TaskRow) => void; + onRun: (taskId: string, work: () => Promise) => Promise; +}) { + const overdue = isOverdue(task, now); + return ( +
+ + + {TRIAGE.filter((t) => t.value !== task.disposition).map((t) => ( + + ))} + +
+ ); +} diff --git a/workbench/control_plane/src/app/projects/components/ProjectTree.tsx b/workbench/control_plane/src/app/projects/components/ProjectTree.tsx index 1d5c73903..0cc609e97 100644 --- a/workbench/control_plane/src/app/projects/components/ProjectTree.tsx +++ b/workbench/control_plane/src/app/projects/components/ProjectTree.tsx @@ -7,7 +7,7 @@ * one recursive list rather than three panes — the indentation IS the * hierarchy. */ -import { ChevronDown, ChevronRight, FolderKanban } from "lucide-react"; +import { ChevronDown, ChevronRight, FolderKanban, Plus } from "lucide-react"; import { useState } from "react"; import type { ProjectRow } from "../lib/api"; @@ -16,6 +16,8 @@ interface Props { roots: ProjectRow[]; selectedId: string | null; onSelect: (project: ProjectRow) => void; + /** Start creating a subproject under this node. Omitted = read-only tree. */ + onAddChild?: (parent: ProjectRow) => void; } function Node({ @@ -23,11 +25,13 @@ function Node({ depth, selectedId, onSelect, + onAddChild, }: { node: ProjectRow; depth: number; selectedId: string | null; onSelect: (project: ProjectRow) => void; + onAddChild?: (parent: ProjectRow) => void; }) { const [open, setOpen] = useState(depth < 1); const children = node.children ?? []; @@ -75,6 +79,20 @@ function Node({ ) : null} + {onAddChild ? ( + // A subproject is created HERE rather than from a dialog that asks + // "which parent?" — the answer is already on screen, and asking for + // it again is how a tree with fifty nodes gets mis-parented rows. + + ) : null} {open && children.length > 0 ? (
    @@ -85,6 +103,7 @@ function Node({ depth={depth + 1} selectedId={selectedId} onSelect={onSelect} + onAddChild={onAddChild} /> ))}
@@ -93,7 +112,7 @@ function Node({ ); } -export function ProjectTree({ roots, selectedId, onSelect }: Props) { +export function ProjectTree({ roots, selectedId, onSelect, onAddChild }: Props) { if (roots.length === 0) { return (

@@ -110,6 +129,7 @@ export function ProjectTree({ roots, selectedId, onSelect }: Props) { depth={0} selectedId={selectedId} onSelect={onSelect} + onAddChild={onAddChild} /> ))} diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx index 13bd7a5ba..405439e12 100644 --- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx +++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx @@ -7,21 +7,36 @@ * (§3.8) — the timeline shows a status change, an assignment, an agent run and * a comment in the same stream, which is the point of the shared spine. */ -import { X } from "lucide-react"; +import { Bot, Image as ImageIcon, Paperclip, X } from "lucide-react"; import { useEffect, useState } from "react"; import { type ActivityRow, + type AttachmentRow, type StatusRow, type TaskRow, + attachmentsApi, projectsApi, } from "../lib/api"; +import { + assigneeLabel, + classify, + parseAssignees, + withAssignee, + withoutAssignee, +} from "../lib/assignees"; interface Props { task: TaskRow; statuses: StatusRow[]; onClose: () => void; onChanged: (task: TaskRow) => void; + /** + * Fired when this panel adds a row the surrounding list does not know about. + * `onChanged` merges one task; a new subtask is a task the board has never + * seen, so it needs a real reload rather than a merge. + */ + onTaskAdded?: () => void; } function describe(activity: ActivityRow): string { @@ -47,16 +62,28 @@ function describe(activity: ActivityRow): string { return `Agent run ${String(meta.agent ?? "")}`.trim(); case "sync": return activity.body ?? "Synced"; + case "attachment": + return activity.body ?? "Attachment changed"; default: return activity.body ?? activity.type; } } -export function TaskPanel({ task, statuses, onClose, onChanged }: Props) { +export function TaskPanel({ + task, + statuses, + onClose, + onChanged, + onTaskAdded, +}: Props) { const [timeline, setTimeline] = useState([]); const [comment, setComment] = useState(""); + const [assignee, setAssignee] = useState(""); + const [subtask, setSubtask] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const assignees = task.assignees ?? []; useEffect(() => { let live = true; @@ -66,11 +93,57 @@ export function TaskPanel({ task, statuses, onClose, onChanged }: Props) { if (live) setTimeline(res.rows); }) .catch((err) => live && setError(String(err.message ?? err))); + attachmentsApi + .list(task.id) + .then((res) => { + if (live) setFiles(res.rows); + }) + // Attachments failing must not blank the panel: the timeline and the + // status control are the reason somebody opened it. + .catch(() => undefined); return () => { live = false; }; }, [task.id]); + async function uploadFiles(picked: FileList | null) { + if (!picked || picked.length === 0) return; + setBusy(true); + setError(null); + try { + // Sequential, not Promise.all: each upload writes a timeline row, and a + // burst of parallel writes would interleave them into an order that does + // not match what the person did. + for (const file of Array.from(picked)) { + await attachmentsApi.upload(task.id, file); + } + const [fresh, tl] = await Promise.all([ + attachmentsApi.list(task.id), + projectsApi.timeline(task.id), + ]); + setFiles(fresh.rows); + setTimeline(tl.rows); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + + async function detach(attachmentId: string) { + setBusy(true); + setError(null); + try { + await attachmentsApi.detach(task.id, attachmentId); + const fresh = await attachmentsApi.list(task.id); + setFiles(fresh.rows); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + async function reload() { const [fresh, tl] = await Promise.all([ projectsApi.task(task.id), @@ -93,6 +166,57 @@ export function TaskPanel({ task, statuses, onClose, onChanged }: Props) { } } + async function saveAssignees(next: string[]) { + setBusy(true); + setError(null); + try { + await projectsApi.setAssignees(task.id, next); + await reload(); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + + async function addAssignees() { + // A whole pasted list at once, not one at a time: the PUT replaces the set + // anyway, so batching them is one event rather than N. + let next = assignees; + for (const who of parseAssignees(assignee)) next = withAssignee(next, who); + // Identity means nothing changed — skipping the PUT is what stops a + // re-assert emitting pm.task.assigned and re-dispatching an agent run. + if (next === assignees) { + setAssignee(""); + return; + } + setAssignee(""); + await saveAssignees(next); + } + + async function addSubtask() { + const title = subtask.trim(); + if (!title) return; + setBusy(true); + setError(null); + try { + // A subtask is a task with a parent (§3.5) — no second endpoint and no + // second table, so it inherits statuses, timeline and assignment whole. + await projectsApi.createTask({ + project_id: task.project_id, + parent_task_id: task.id, + title, + }); + setSubtask(""); + await reload(); + onTaskAdded?.(); + } catch (err) { + setError(String((err as Error).message)); + } finally { + setBusy(false); + } + } + async function addComment() { const body = comment.trim(); if (!body) return; @@ -144,16 +268,129 @@ export function TaskPanel({ task, statuses, onClose, onChanged }: Props) { ))} - {task.assignees?.length ? ( -

- Assigned to {task.assignees.join(", ")} -

- ) : null} +
+ Assignees +
+ {assignees.map((who) => { + const kind = classify(who); + return ( + " : who} + className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs ${ + kind === "unknown" + ? "border border-border text-muted-foreground" + : "bg-muted text-foreground" + }`} + > + {/* Agents and people are one vocabulary (D-PM-4), so the + difference is an icon, never a separate field. */} + {kind === "agent" ? : null} + {assigneeLabel(who)} + + + ); + })} + {assignees.length === 0 ? ( + Nobody yet + ) : null} +
+ setAssignee(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addAssignees(); + } + }} + onBlur={() => void addAssignees()} + placeholder="email or agent:name" + aria-label="Add an assignee" + className="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" + /> +
{task.description ? (

{task.description}

) : null} +
+ Files +
+ {files.map((f) => ( +
+ {f.kind === "image" ? ( + + ) : ( + + )} + + {f.name} + + + {Math.max(1, Math.round(f.size / 1024))} KB + + +
+ ))} + {files.length === 0 ? ( +

Nothing attached.

+ ) : null} +
+ { + void uploadFiles(e.target.files); + // Reset so picking the SAME file twice still fires a change. + e.target.value = ""; + }} + className="mt-1 w-full text-xs text-muted-foreground file:mr-2 file:rounded-md file:border file:border-border file:bg-background file:px-2 file:py-1 file:text-xs file:text-foreground" + /> +
+
+ Subtask + setSubtask(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addSubtask(); + } + }} + placeholder="Break this down…" + aria-label="Add a subtask" + className="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" + /> +
{error ? ( diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts index ff32b56b7..bfd34d9bf 100644 --- a/workbench/control_plane/src/app/projects/lib/api.ts +++ b/workbench/control_plane/src/app/projects/lib/api.ts @@ -155,3 +155,109 @@ export const projectsApi = { body: JSON.stringify({ positions }), }), }; + +/** + * The personal lens (WS-27e). + * + * Same store, same rows, same proxy — there is no second task API, because + * there is no second task table. Identity comes from the session on the server + * side, so nothing here takes a member parameter: no request can be shaped to + * read or write somebody else's practice. + */ +export const myWorkApi = { + inbox: (params: Record = {}) => { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") qs.set(key, String(value)); + } + const query = qs.toString(); + return call<{ rows: MyTaskApiRow[]; total: number }>( + `my/inbox${query ? `?${query}` : ""}` + ); + }, + + contexts: () => + call<{ rows: Array<{ context: string; total: number }>; total: number }>( + "my/contexts" + ), + + capture: (payload: { + title: string; + next_action?: string | null; + context?: string | null; + due_at?: string | null; + }) => call("my/tasks", { method: "POST", body: JSON.stringify(payload) }), + + setPersonal: (taskId: string, payload: Record) => + call>(`tasks/${taskId}/personal`, { + method: "PATCH", + body: JSON.stringify(payload), + }), + + // Completion moves the task's SHARED status — the cohesion the one-store + // design buys. Ticking something off here ticks it off on the team's board. + complete: (taskId: string) => + call(`tasks/${taskId}/complete`, { method: "POST", body: "{}" }), + + defer: (taskId: string, until: string) => + call>(`tasks/${taskId}/defer`, { + method: "POST", + body: JSON.stringify({ until }), + }), +}; + +export interface AttachmentRow { + attachment_id: string; + kind: "image" | "file"; + name: string; + mime: string; + size: number; + added_by?: string | null; + created_at?: string | null; + url: string; +} + +/** + * Attachments (WS-27i). + * + * The upload is a raw multipart POST rather than a `call()` — that helper + * forces JSON. It still goes through the BFF proxy, so identity travels the + * same way everything else does. + */ +export const attachmentsApi = { + list: (taskId: string) => + call<{ rows: AttachmentRow[]; total: number }>(`tasks/${taskId}/attachments`), + + upload: async (taskId: string, file: File): Promise => { + const body = new FormData(); + body.append("file", file); + const res = await fetch(`/api/projects/tasks/${taskId}/attachments`, { + method: "POST", + body, + }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : null; + if (!res.ok) { + throw new ProjectsApiError( + parsed?.detail ?? `Upload failed (${res.status})`, + res.status + ); + } + return parsed as AttachmentRow; + }, + + detach: (taskId: string, attachmentId: string) => + call<{ removed: number }>(`tasks/${taskId}/attachments/${attachmentId}`, { + method: "DELETE", + }), +}; + +/** What `/my/inbox` returns: the task row with this member's overlay merged on. */ +export interface MyTaskApiRow extends TaskRow { + disposition: string; + is_triaged: boolean; + next_action?: string | null; + context?: string | null; + energy?: string | null; + is_two_minute?: boolean; +} diff --git a/workbench/control_plane/src/app/projects/lib/assignees.test.ts b/workbench/control_plane/src/app/projects/lib/assignees.test.ts new file mode 100644 index 000000000..b23133f3e --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/assignees.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + assigneeLabel, + classify, + normalize, + parseAssignees, + withAssignee, + withoutAssignee, +} from "./assignees"; + +describe("normalize", () => { + it("matches the server: trimmed and lowercased", () => { + expect(normalize(" Priya@X.com ")).toBe("priya@x.com"); + }); +}); + +describe("classify", () => { + it("recognises the agent prefix", () => { + expect(classify("agent:researcher")).toBe("agent"); + expect(classify("Agent:Researcher")).toBe("agent"); + }); + + it("does not call a bare prefix an agent", () => { + // `agent:` names nobody, and treating it as an agent would show a chip for + // a dispatch target that cannot exist. + expect(classify("agent:")).toBe("unknown"); + }); + + it("recognises an email", () => { + expect(classify("priya@fracktal.in")).toBe("person"); + }); + + it("flags something that is neither, without rejecting it", () => { + // A hint, not a rule — the server accepts any non-empty string, and the + // failure worth surfacing is a typo that assigns work to nobody. + expect(classify("priya")).toBe("unknown"); + expect(classify("priya@fracktal")).toBe("unknown"); + }); +}); + +describe("parseAssignees", () => { + it("splits on commas, semicolons and newlines", () => { + expect(parseAssignees("a@x.com, b@x.com; c@x.com\nd@x.com")).toEqual([ + "a@x.com", + "b@x.com", + "c@x.com", + "d@x.com", + ]); + }); + + it("does NOT split on spaces", () => { + // A pasted list arrives as `Priya ` often enough that + // splitting on whitespace would shred it into tokens assigning nobody. + expect(parseAssignees("priya ")).toEqual(["priya "]); + }); + + it("drops empties left by trailing separators", () => { + expect(parseAssignees("a@x.com,, ,\n")).toEqual(["a@x.com"]); + }); + + it("dedupes after normalising, keeping first-seen order", () => { + expect(parseAssignees("B@x.com, a@x.com, b@X.com")).toEqual([ + "b@x.com", + "a@x.com", + ]); + }); + + it("keeps agent targets alongside people", () => { + expect(parseAssignees("agent:Researcher, priya@x.com")).toEqual([ + "agent:researcher", + "priya@x.com", + ]); + }); +}); + +describe("withAssignee", () => { + it("appends a new assignee", () => { + expect(withAssignee(["a@x.com"], "B@x.com")).toEqual(["a@x.com", "b@x.com"]); + }); + + it("returns the SAME array when the assignee is already there", () => { + // Identity is the signal a caller uses to skip the PUT — and skipping it + // is what stops a re-assert emitting pm.task.assigned and re-dispatching + // an agent run. + const current = ["a@x.com"]; + expect(withAssignee(current, "A@X.com")).toBe(current); + }); + + it("ignores blank input", () => { + const current = ["a@x.com"]; + expect(withAssignee(current, " ")).toBe(current); + }); +}); + +describe("withoutAssignee", () => { + it("removes case-insensitively", () => { + expect(withoutAssignee(["a@x.com", "b@x.com"], "A@X.com")).toEqual(["b@x.com"]); + }); + + it("is a no-op for somebody absent", () => { + expect(withoutAssignee(["a@x.com"], "z@x.com")).toEqual(["a@x.com"]); + }); +}); + +describe("assigneeLabel", () => { + it("shows an agent by name, without the prefix", () => { + expect(assigneeLabel("agent:Researcher")).toBe("researcher"); + }); + + it("shows a person by address", () => { + expect(assigneeLabel("Priya@x.com")).toBe("priya@x.com"); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/assignees.ts b/workbench/control_plane/src/app/projects/lib/assignees.ts new file mode 100644 index 000000000..47c6c1a67 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/assignees.ts @@ -0,0 +1,104 @@ +/** + * Projects · the assignee vocabulary, client side. + * + * Spec: `ai-company-brain/specs/project_management_app.md` §3.7 · **D-PM-4**. + * + * An assignee is an **email or `agent:`** — one vocabulary for both + * species. That is what makes handing work to an agent the same action as + * handing it to a colleague, rather than a parallel feature with its own field, + * its own picker and its own permission story. + * + * These functions mirror the server's normalisation (`tasks.py::set_assignees` + * strips and lowercases) so the chips a member sees before saving match the set + * that comes back after. They deliberately do **not** reject anything the + * server would accept: inventing a client-side rule the API does not enforce is + * how a UI starts refusing valid data. + */ + +export type AssigneeKind = "person" | "agent" | "unknown"; + +/** The prefix that makes an actor an agent. Lowercased by {@link normalize}. */ +export const AGENT_PREFIX = "agent:"; + +/** + * One assignee, normalised the way the server stores it. + * + * Lowercased because `pm_task_assignees` holds one row per `(task, assignee)` + * and the API lowercases on write — leaving case here would show a member + * "Priya@…" and "priya@…" as two people until the next reload proved otherwise. + */ +export function normalize(raw: string): string { + return raw.trim().toLowerCase(); +} + +/** + * What kind of actor a token names. + * + * `unknown` is a **hint, not a rejection** — the server accepts any non-empty + * string, so the UI's job is to say "that does not look like an email or an + * agent" and let the member decide. A typo'd address that silently assigns + * work to nobody is the failure worth surfacing. + */ +export function classify(assignee: string): AssigneeKind { + const value = normalize(assignee); + if (value.startsWith(AGENT_PREFIX)) { + return value.length > AGENT_PREFIX.length ? "agent" : "unknown"; + } + // Deliberately loose: something@something.something. A stricter regex would + // reject addresses that are legal and deliverable. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "person" : "unknown"; +} + +/** + * Turn typed text into the assignee set to send. + * + * Splits on commas, semicolons and newlines — never on spaces, because a + * pasted list often arrives as `"Priya , Sam"` and splitting on + * whitespace would shred it into tokens that assign work to nobody. + * + * Order is preserved and duplicates are dropped **after** normalisation, so + * `Priya@x.com, priya@x.com` is one assignee rather than a set the server + * silently collapses behind the member's back. + */ +export function parseAssignees(input: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const part of input.split(/[,;\n]/)) { + const value = normalize(part); + if (!value || seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +/** + * Add one assignee to a set, or return the set unchanged. + * + * Returning the *same array* when nothing changed is deliberate: it lets a + * caller skip a PUT that would emit a `pm.task.assigned` event for an assignee + * who is already there — which WS-27f keys agent dispatch off, so a re-assert + * would re-dispatch a run. + */ +export function withAssignee(current: readonly string[], raw: string): string[] { + const value = normalize(raw); + if (!value) return current as string[]; + const existing = current.map(normalize); + if (existing.includes(value)) return current as string[]; + return [...existing, value]; +} + +/** Remove one assignee. Removing somebody absent is a no-op, not an error. */ +export function withoutAssignee( + current: readonly string[], + raw: string +): string[] { + const value = normalize(raw); + return current.map(normalize).filter((a) => a !== value); +} + +/** The label for a chip: an agent shows its name, a person their address. */ +export function assigneeLabel(assignee: string): string { + const value = normalize(assignee); + return value.startsWith(AGENT_PREFIX) ? value.slice(AGENT_PREFIX.length) : value; +} diff --git a/workbench/control_plane/src/app/projects/lib/mywork.test.ts b/workbench/control_plane/src/app/projects/lib/mywork.test.ts new file mode 100644 index 000000000..2ecfdf031 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/mywork.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { + DISPOSITION_LANES, + OTHER_LANE, + actionLine, + groupByDisposition, + isOverdue, + sortMyTasks, + twoMinuteTasks, + untriagedCount, + type MyTaskRow, +} from "./mywork"; + +function task(over: Partial & { id: string }): MyTaskRow { + return { + project_id: "p", + root_project_id: "p", + status_id: "s", + title: `task ${over.id}`, + disposition: "NEXT", + is_triaged: true, + ...over, + }; +} + +describe("isOverdue", () => { + const now = new Date("2026-08-06T12:00:00Z"); + + it("is false for a task with no due date", () => { + expect(isOverdue(task({ id: "a" }), now)).toBe(false); + }); + + it("is true only once the date has passed", () => { + expect(isOverdue({ due_at: "2026-08-06T11:59:00Z" }, now)).toBe(true); + expect(isOverdue({ due_at: "2026-08-06T12:01:00Z" }, now)).toBe(false); + }); + + it("is not overdue at the exact instant it falls due", () => { + // Pins `<` rather than `<=`: a task is late once the moment has passed, + // not at the moment itself. + expect(isOverdue({ due_at: "2026-08-06T12:00:00Z" }, now)).toBe(false); + }); + + it("treats an unparseable date as not overdue", () => { + // Better to under-alarm than to paint every task red because one row + // carried a malformed date. + expect(isOverdue({ due_at: "not a date" }, now)).toBe(false); + }); +}); + +describe("sortMyTasks", () => { + it("orders dated work soonest-first", () => { + const rows = [ + task({ id: "late", due_at: "2026-09-01T00:00:00Z" }), + task({ id: "soon", due_at: "2026-08-07T00:00:00Z" }), + ]; + expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["soon", "late"]); + }); + + it("puts undated work BELOW dated work, not above it", () => { + // The load-bearing half: a task nobody dated is not more urgent than one + // due tomorrow, and the opposite order is how the list stops being read. + const rows = [ + task({ id: "undated", created_at: "2020-01-01T00:00:00Z" }), + task({ id: "dated", due_at: "2030-01-01T00:00:00Z" }), + ]; + expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["dated", "undated"]); + }); + + it("falls back to creation order for undated ties", () => { + const rows = [ + task({ id: "newer", created_at: "2026-08-06T00:00:00Z" }), + task({ id: "older", created_at: "2026-01-01T00:00:00Z" }), + ]; + expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["older", "newer"]); + }); + + it("does not mutate its input", () => { + const rows = [task({ id: "b", due_at: "2030-01-01T00:00:00Z" }), task({ id: "a" })]; + const before = rows.map((t) => t.id); + sortMyTasks(rows); + expect(rows.map((t) => t.id)).toEqual(before); + }); +}); + +describe("groupByDisposition", () => { + it("shows every work lane even when empty", () => { + // An empty Next list means "you have triaged nothing into today" — a lane + // that vanishes when empty cannot say that. + const lanes = groupByDisposition([]); + expect(lanes.map((l) => l.lane)).toEqual([...DISPOSITION_LANES]); + }); + + it("routes each disposition to its own lane", () => { + const lanes = groupByDisposition([ + task({ id: "i", disposition: "INBOX" }), + task({ id: "n", disposition: "NEXT" }), + task({ id: "w", disposition: "WAITING" }), + task({ id: "s", disposition: "SOMEDAY" }), + ]); + const byLane = Object.fromEntries( + lanes.map((l) => [l.lane, l.tasks.map((t) => t.id)]) + ); + expect(byLane).toMatchObject({ + INBOX: ["i"], + NEXT: ["n"], + WAITING: ["w"], + SOMEDAY: ["s"], + }); + }); + + it("files a non-lane disposition rather than dropping it", () => { + // REFERENCE is not a work lane, but a task that disappears from the only + // view a member has is worse than one shown in the wrong place. + const lanes = groupByDisposition([task({ id: "r", disposition: "REFERENCE" })]); + const other = lanes.find((l) => l.lane === OTHER_LANE); + expect(other?.tasks.map((t) => t.id)).toEqual(["r"]); + }); + + it("hides the filed lane when it is empty", () => { + expect(groupByDisposition([task({ id: "n" })]).some((l) => l.lane === OTHER_LANE)).toBe( + false + ); + }); + + it("orders within each lane", () => { + const lanes = groupByDisposition([ + task({ id: "late", disposition: "NEXT", due_at: "2030-01-01T00:00:00Z" }), + task({ id: "soon", disposition: "NEXT", due_at: "2026-08-07T00:00:00Z" }), + ]); + expect(lanes.find((l) => l.lane === "NEXT")?.tasks.map((t) => t.id)).toEqual([ + "soon", + "late", + ]); + }); +}); + +describe("untriagedCount", () => { + it("counts only the rows the member has never stated a disposition for", () => { + // The distinction the server preserves by deriving instead of storing: an + // assigned task reads as NEXT while still being untriaged. + // Deliberately lopsided — one untriaged against two triaged. A balanced + // fixture gives the same answer whichever side is counted, and lets an + // inverted predicate pass. + const rows = [ + task({ id: "derived", disposition: "NEXT", is_triaged: false }), + task({ id: "stated", disposition: "NEXT", is_triaged: true }), + task({ id: "filed", disposition: "SOMEDAY", is_triaged: true }), + ]; + expect(untriagedCount(rows)).toBe(1); + }); +}); + +describe("twoMinuteTasks", () => { + it("selects only tasks explicitly marked two-minute", () => { + const rows = [ + task({ id: "quick", is_two_minute: true }), + task({ id: "unset" }), + task({ id: "no", is_two_minute: false }), + ]; + expect(twoMinuteTasks(rows).map((t) => t.id)).toEqual(["quick"]); + }); +}); + +describe("actionLine", () => { + it("prefers the next action over the title", () => { + expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: "Email Priya" }))) + .toBe("Email Priya"); + }); + + it("falls back to the title when the next action is blank", () => { + expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: " " }))).toBe( + "Q3 pricing" + ); + expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: null }))).toBe( + "Q3 pricing" + ); + }); +}); diff --git a/workbench/control_plane/src/app/projects/lib/mywork.ts b/workbench/control_plane/src/app/projects/lib/mywork.ts new file mode 100644 index 000000000..b1a180246 --- /dev/null +++ b/workbench/control_plane/src/app/projects/lib/mywork.ts @@ -0,0 +1,153 @@ +/** + * Projects · the personal lens — grouping and ordering for "My work". + * + * Spec: `ai-company-brain/specs/project_management_app.md` §3.11-§3.12, §6.1 · + * **D-PM-6 (revised)** · ticket WS-27e. + * + * Pure functions only. The server already decided *which* rows are mine and + * what my effective disposition is (`GET /projects/my/inbox` derives it when I + * have not triaged); this module decides only how they are laid out. Keeping + * the derivation server-side and the layout here is what stops the browser + * growing a second, drifting answer to "what is my next action". + */ + +import type { TaskRow } from "./api"; + +/** + * A task as the personal lens sees it: the shared row plus **my** overlay. + * + * There is one task row and one status — `disposition` and the rest are mine + * alone, which is why two people can hold different ones for the same task. + */ +export interface MyTaskRow extends TaskRow { + /** Stated by me, or derived by the server when I have not triaged it. */ + disposition: string; + /** False when the disposition above was derived rather than chosen. */ + is_triaged: boolean; + next_action?: string | null; + context?: string | null; + energy?: string | null; + is_two_minute?: boolean; +} + +/** + * The lanes My Work shows, in order. + * + * Deliberately four of the eight dispositions. `DONE`/`TRASH` are excluded by + * the endpoint unless asked for, and `PROJECT`/`REFERENCE` are filing states + * rather than work states — surfacing them as lanes would make the daily view + * a filing cabinet. They are still reachable, via {@link OTHER_LANE}. + */ +export const DISPOSITION_LANES = ["INBOX", "NEXT", "WAITING", "SOMEDAY"] as const; + +export type DispositionLane = (typeof DISPOSITION_LANES)[number]; + +/** Where a disposition that is not a lane lands, so nothing is ever dropped. */ +export const OTHER_LANE = "OTHER"; + +export const LANE_LABELS: Record = { + INBOX: "Inbox", + NEXT: "Next actions", + WAITING: "Waiting on", + SOMEDAY: "Someday", + OTHER: "Filed", +}; + +/** + * True when a task's due date has passed. + * + * `now` is a parameter rather than a `Date.now()` call so the boundary is + * testable — a function that reads the clock itself can only be tested by + * mocking time, and "is this overdue" is exactly the kind of off-by-a-day + * question that deserves a plain assertion. + */ +export function isOverdue(task: Pick, now: Date): boolean { + if (!task.due_at) return false; + const due = new Date(task.due_at); + return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime(); +} + +/** + * Order within a lane: dated work first, soonest first; undated after it. + * + * Undated tasks sort **below** dated ones rather than above. A task nobody gave + * a date is not more urgent than one due tomorrow, and putting the undated pile + * on top is how a personal list stops being read. + */ +export function sortMyTasks(rows: readonly MyTaskRow[]): MyTaskRow[] { + return [...rows].sort((a, b) => { + const aDue = a.due_at ? new Date(a.due_at).getTime() : null; + const bDue = b.due_at ? new Date(b.due_at).getTime() : null; + const aHas = aDue !== null && !Number.isNaN(aDue); + const bHas = bDue !== null && !Number.isNaN(bDue); + if (aHas && bHas && aDue !== bDue) return (aDue as number) - (bDue as number); + if (aHas !== bHas) return aHas ? -1 : 1; + return String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")); + }); +} + +/** + * Split my work into its lanes, each already ordered. + * + * Every lane is present even when empty — an empty Next list is a real and + * important state ("you have triaged nothing into today"), and a lane that + * disappears when empty cannot say that. + */ +export function groupByDisposition( + rows: readonly MyTaskRow[] +): Array<{ lane: string; label: string; tasks: MyTaskRow[] }> { + const buckets = new Map(); + for (const lane of DISPOSITION_LANES) buckets.set(lane, []); + buckets.set(OTHER_LANE, []); + + for (const row of rows) { + const key = (DISPOSITION_LANES as readonly string[]).includes(row.disposition) + ? row.disposition + : OTHER_LANE; + buckets.get(key)?.push(row); + } + + const lanes = [...DISPOSITION_LANES, OTHER_LANE].map((lane) => ({ + lane, + label: LANE_LABELS[lane] ?? lane, + tasks: sortMyTasks(buckets.get(lane) ?? []), + })); + // "Filed" is the only lane hidden when empty: unlike the four work lanes, an + // empty one says nothing a member needs to know. + return lanes.filter((l) => l.lane !== OTHER_LANE || l.tasks.length > 0); +} + +/** + * How many of my tasks I have never actually looked at. + * + * The Weekly Review's whole question, and the reason the server derives + * dispositions instead of storing them on first read: a stored default would + * make every untriaged task indistinguishable from a triaged one and this + * count would be permanently zero. + */ +export function untriagedCount(rows: readonly MyTaskRow[]): number { + return rows.filter((r) => !r.is_triaged).length; +} + +/** + * Tasks I marked as taking under two minutes. + * + * GTD's two-minute rule is the one piece of the method that pays off inside a + * single sitting, so it gets its own surface rather than being a badge someone + * has to notice. + */ +export function twoMinuteTasks(rows: readonly MyTaskRow[]): MyTaskRow[] { + return sortMyTasks(rows.filter((r) => r.is_two_minute === true)); +} + +/** + * What to show as the actionable line for a task. + * + * The next action when I wrote one, the title otherwise. "Email Priya the + * revised quote" is a next action; "Q3 pricing" is a project title, and a list + * of project titles is a list nobody can start work from. + */ +export function actionLine(task: MyTaskRow): string { + const next = (task.next_action ?? "").trim(); + return next || task.title; +} diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx index d6cb4ab70..d8371cf41 100644 --- a/workbench/control_plane/src/app/projects/page.tsx +++ b/workbench/control_plane/src/app/projects/page.tsx @@ -11,6 +11,7 @@ * hand-edited slug shows nothing the caller could not already reach (R9, and * `lib/tree.filterByCenter`'s own test says so). */ +import { Plus } from "lucide-react"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; @@ -21,6 +22,7 @@ import { type TaskRow, projectsApi, } from "./lib/api"; +import { MyWork } from "./components/MyWork"; import { ProjectTree } from "./components/ProjectTree"; import { TaskBoard } from "./components/TaskBoard"; import { TaskList } from "./components/TaskList"; @@ -40,10 +42,27 @@ function ProjectsWorkspace() { const [statuses, setStatuses] = useState([]); const [tasks, setTasks] = useState([]); const [openTask, setOpenTask] = useState(null); + // The panel's statuses are held apart from the selected project's, because a + // task opened from My work can belong to a project that is not selected — + // and a panel offering another project's statuses would offer transitions + // that do not exist. + const [panelStatuses, setPanelStatuses] = useState([]); + const [mine, setMine] = useState(false); const [mode, setMode] = useState("board"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Creating a project: `undefined` = not creating, `null` = a new department + // at the root, a row = a subproject under it. Three states in one because + // "which parent" is the only question, and a separate boolean would let the + // two disagree. + const [creatingUnder, setCreatingUnder] = useState( + undefined + ); + const [newName, setNewName] = useState(""); + const [newTask, setNewTask] = useState(""); + const [treeKey, setTreeKey] = useState(0); + // The tree, plus every root's grants — the grants are what the Center filter // reads, and fetching them per root keeps `filterByCenter` a pure function // over data the page already holds. @@ -72,7 +91,7 @@ function ProjectsWorkspace() { return () => { live = false; }; - }, []); + }, [treeKey]); const visibleRoots = useMemo( () => filterByCenter(roots, grants, center), @@ -112,6 +131,69 @@ function ProjectsWorkspace() { if (selected) void loadProject(selected); }, [selected, loadProject]); + // Opening a task always resolves ITS project's statuses. From the board that + // is the set already loaded; from My work it may be any project the member + // is assigned into, so it is fetched. + const openWithStatuses = useCallback( + async (task: TaskRow) => { + setOpenTask(task); + if (selected && task.root_project_id === selected.id) { + setPanelStatuses(statuses); + return; + } + try { + const res = await projectsApi.statuses(task.root_project_id); + setPanelStatuses(res.rows); + } catch { + // A panel with no status options is degraded but usable; failing to + // open the task at all because its lanes could not be listed is not. + setPanelStatuses([]); + } + }, + [selected, statuses] + ); + + async function submitProject(event: React.FormEvent) { + event.preventDefault(); + const name = newName.trim(); + if (!name) return; + setError(null); + try { + const created = await projectsApi.createProject({ + name, + parent_project_id: creatingUnder ? creatingUnder.id : null, + }); + setNewName(""); + setCreatingUnder(undefined); + setTreeKey((k) => k + 1); + // A subproject is not selectable until the refreshed tree carries it, so + // only a new root is selected here — selecting a stale row would show an + // empty board and read as a failed create. + if (!creatingUnder) { + setMine(false); + setSelected(created); + } + } catch (err) { + setError(String((err as Error).message)); + } + } + + async function submitTask(event: React.FormEvent) { + event.preventDefault(); + const title = newTask.trim(); + if (!title || !selected) return; + setNewTask(""); + setError(null); + try { + // Status is deliberately not sent: the API picks the project's default, + // so the browser never has to know which lane a new task starts in. + await projectsApi.createTask({ project_id: selected.id, title }); + await loadProject(selected); + } catch (err) { + setError(String((err as Error).message)); + } + } + async function handleDrop( task: TaskRow, writes: ReturnType, @@ -145,16 +227,67 @@ function ProjectsWorkspace() { return (
@@ -162,15 +295,20 @@ function ProjectsWorkspace() {

- {selected?.name ?? "No project selected"} + {mine ? "My work" : selected?.name ?? "No project selected"}

- {selected?.description ? ( + {mine ? ( +

+ Assigned to you, plus your own — one store, so finishing here + finishes it on the board. +

+ ) : selected?.description ? (

{selected.description}

) : null}
-
+
{(["board", "list"] as ViewMode[]).map((m) => ( -
-
- - {/* Filters */} -
- - - - {visible.length} {visible.length === 1 ? "person" : "people"} - -
- - {/* Roster */} -
- {loading ? ( -
- Loading people… -
- ) : visible.length === 0 ? ( -
- - {orgPeople.length === 0 - ? "No people yet — add your team or seed from the HR import." - : "No one matches those filters."} -
- ) : ( -
- {visible.map((p) => ( - setEditing(p)} /> - ))} -
- )} -
- - {editing && ( - setEditing(null)} - /> - )} -
- ); -} - -function PersonRow({ p, onOpen }: { p: OrgPerson; onOpen: () => void }) { - const cap = p.capacityHoursPerWeek ?? 0; - const load = p.currentLoadHoursPerWeek ?? 0; - const pct = cap > 0 ? Math.min(100, Math.round((load / cap) * 100)) : 0; - const over = cap > 0 && load > cap; - const initial = (p.name || "?").trim().charAt(0).toUpperCase(); - - return ( - - ); -} diff --git a/workbench/control_plane/src/app/tasks/components/PersonEditor.tsx b/workbench/control_plane/src/app/tasks/components/PersonEditor.tsx deleted file mode 100644 index 359d9c042..000000000 --- a/workbench/control_plane/src/app/tasks/components/PersonEditor.tsx +++ /dev/null @@ -1,324 +0,0 @@ -"use client"; - -import { useMemo, useRef, useState } from "react"; -import { X, Loader2, Upload, Check, Plus } from "lucide-react"; -import { useTaskStore } from "../lib/taskStore"; -import { OrgPerson, OrgPersonWrite } from "../lib/types"; - -/** - * PersonEditor — add or edit one HR record: role/title/manager/status/capacity, - * a chip skills editor (showing each skill's provenance), the ClickUp link, and - * résumé ingestion (upload PDF/DOCX → skills auto-merge). `person=null` = create. - */ -export function PersonEditor({ - person, - onClose, -}: { - person: OrgPerson | null; - onClose: () => void; -}) { - const savePerson = useTaskStore((s) => s.savePerson); - const uploadPersonResume = useTaskStore((s) => s.uploadPersonResume); - const orgPeople = useTaskStore((s) => s.orgPeople); - - const [name, setName] = useState(person?.name ?? ""); - const [email, setEmail] = useState(person?.email ?? ""); - const [role, setRole] = useState(person?.role ?? ""); - const [title, setTitle] = useState(person?.title ?? ""); - const [department, setDepartment] = useState(person?.department ?? ""); - const [team, setTeam] = useState(person?.team ?? ""); - const [managerId, setManagerId] = useState(person?.managerId ?? ""); - const [status, setStatus] = useState(person?.status ?? "active"); - const [capacity, setCapacity] = useState( - person?.capacityHoursPerWeek?.toString() ?? "" - ); - const [load, setLoad] = useState( - person?.currentLoadHoursPerWeek?.toString() ?? "" - ); - const [clickupId, setClickupId] = useState(person?.providerUserId ?? ""); - const [skills, setSkills] = useState(person?.skills ?? []); - const [skillInput, setSkillInput] = useState(""); - const skillSource = person?.skillsSource ?? {}; - - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [uploading, setUploading] = useState(false); - const [notice, setNotice] = useState(null); - const fileRef = useRef(null); - - const managers = useMemo( - () => orgPeople.filter((p) => p.id !== person?.id), - [orgPeople, person?.id] - ); - - const addSkill = (raw: string) => { - const s = raw.trim().toLowerCase(); - if (s && !skills.some((k) => k.toLowerCase() === s)) { - setSkills((prev) => [...prev, s]); - } - setSkillInput(""); - }; - - const removeSkill = (s: string) => - setSkills((prev) => prev.filter((k) => k !== s)); - - const buildBody = (): OrgPersonWrite => ({ - name: name.trim(), - email: email.trim() || undefined, - role: role.trim() || undefined, - title: title.trim() || undefined, - department: department.trim() || undefined, - team: team.trim() || undefined, - managerId: managerId || undefined, - status, - skills, - capacityHoursPerWeek: capacity ? Number(capacity) : undefined, - currentLoadHoursPerWeek: load ? Number(load) : undefined, - providerUserId: clickupId.trim() || undefined, - }); - - const save = async () => { - if (!name.trim()) { - setError("Name is required."); - return; - } - setSaving(true); - setError(null); - try { - await savePerson(person?.id ?? null, buildBody()); - onClose(); - } catch (e) { - setError((e as Error).message || "Couldn't save."); - setSaving(false); - } - }; - - const onResumeFile = async (file: File | undefined) => { - if (!file || !person?.id) return; - setUploading(true); - setNotice(null); - setError(null); - try { - const { addedSkills } = await uploadPersonResume(person.id, file); - // Reflect the merged skills in the open form too. - setSkills((prev) => { - const have = new Set(prev.map((s) => s.toLowerCase())); - return [...prev, ...addedSkills.filter((s) => !have.has(s.toLowerCase()))]; - }); - setNotice( - addedSkills.length - ? `Added ${addedSkills.length} skill${addedSkills.length === 1 ? "" : "s"}: ${addedSkills.join(", ")}` - : "Résumé parsed — no new skills found." - ); - } catch (e) { - setError((e as Error).message || "Couldn't process that résumé."); - } finally { - setUploading(false); - if (fileRef.current) fileRef.current.value = ""; - } - }; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-

- {person ? `Edit ${person.name}` : "Add person"} -

- -
- - {/* Body */} -
-
- - setName(e.target.value)} - className={INPUT} placeholder="Full name" /> - - - setEmail(e.target.value)} - className={INPUT} placeholder="name@company.com" /> - - - setTitle(e.target.value)} - className={INPUT} placeholder="e.g. Senior Engineer" /> - - - setRole(e.target.value)} - className={INPUT} placeholder="e.g. Software Engineer" /> - - - setDepartment(e.target.value)} - className={INPUT} placeholder="e.g. Engineering" /> - - - setTeam(e.target.value)} - className={INPUT} placeholder="e.g. Platform" /> - - - - - - - - - setCapacity(e.target.value)} - type="number" min={0} className={INPUT} placeholder="e.g. 40" /> - - - setLoad(e.target.value)} - type="number" min={0} className={INPUT} placeholder="e.g. 20" /> - - - setClickupId(e.target.value)} - className={INPUT} placeholder="Assignment target (numeric id)" /> - -
- - {/* Skills */} - -
- {skills.map((s) => ( - - {s} - - - ))} -
- setSkillInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - addSkill(skillInput); - } - }} - placeholder={skills.length ? "add…" : "add a skill…"} - className="min-w-[6rem] flex-1 bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground" - /> - {skillInput.trim() && ( - - )} -
-
-
- - {/* Résumé ingestion */} - - {person ? ( -
- onResumeFile(e.target.files?.[0])} - className="hidden" - /> - - {notice && ( -
- {notice} -
- )} -
- ) : ( -

- Save this person first, then upload a résumé to auto-extract skills. -

- )} -
- - {error &&
{error}
} -
- - {/* Footer */} -
-
- - -
-
-
- ); -} - -const INPUT = - "w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none focus:border-primary transition-colors"; - -function Field({ - label, - children, - full, -}: { - label: string; - children: React.ReactNode; - full?: boolean; -}) { - return ( - - ); -} diff --git a/workbench/control_plane/src/app/tasks/components/ProjectTasksView.tsx b/workbench/control_plane/src/app/tasks/components/ProjectTasksView.tsx deleted file mode 100644 index a51535578..000000000 --- a/workbench/control_plane/src/app/tasks/components/ProjectTasksView.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -import { useMemo, useSyncExternalStore } from "react"; -import { - FolderKanban, - LayoutList, - Columns3, - AlertTriangle, - CheckCircle2, - ExternalLink, -} from "lucide-react"; -import { useTaskStore } from "../lib/taskStore"; -import { GtdProject } from "../lib/types"; -import { applyFilters, applySort } from "../lib/ordering"; -import { SourceBadge } from "./SourceBadge"; -import { TaskToolbar } from "./TaskToolbar"; -import { TaskListGrouped } from "./TaskListGrouped"; -import { TaskBoard } from "./TaskBoard"; - -// The right-hand pane for a selected project: its tasks as a list OR Kanban -// board — the SAME UI as Next Actions. Stages come from the project's home: -// SYNCED (ClickUp) → that workspace's own statuses, two-way synced (a drag -// re-files provider_status → ClickUp update_task). -// LOCAL → the global workflow stages. -// Clicking a task opens it (with subtasks) in the focus modal, as elsewhere. - -// Same sticky List/Board toggle recipe as ItemList (per-browser, SSR-safe). -const MODE_KEY = "cc.tasks.projectViewMode"; -const listeners = new Set<() => void>(); -function subscribeMode(cb: () => void) { - listeners.add(cb); - const onStorage = (e: StorageEvent) => { if (e.key === MODE_KEY) cb(); }; - window.addEventListener("storage", onStorage); - return () => { - listeners.delete(cb); - window.removeEventListener("storage", onStorage); - }; -} -function readMode(): "list" | "board" { - try { - return window.localStorage.getItem(MODE_KEY) === "board" ? "board" : "list"; - } catch { - return "list"; - } -} -function setModePersist(m: "list" | "board") { - try { window.localStorage.setItem(MODE_KEY, m); } catch { /* private mode */ } - listeners.forEach((cb) => cb()); -} - -export function ProjectTasksView({ project }: { project: GtdProject }) { - const items = useTaskStore((s) => s.items); - const accounts = useTaskStore((s) => s.accounts); - const filters = useTaskStore((s) => s.filters); - const sort = useTaskStore((s) => s.sort); - const mode = useSyncExternalStore(subscribeMode, readMode, () => "list"); - - const isSynced = project.source !== "LOCAL"; - // A ClickUp project's stages are its workspace's statuses; a local project - // uses the global workflow stages (handled by the child when stages omitted). - const providerStages = useMemo(() => { - if (!isSynced) return undefined; - const acct = accounts.find((a) => a.id === project.accountId); - return acct?.statuses?.length ? acct.statuses : undefined; - }, [isSynced, accounts, project.accountId]); - - // Top-level project tasks (subtasks stay nested in the task detail). - const projectTasks = useMemo( - () => - items.filter( - (i) => i.projectId === project.id && !i.parentItemId, - ), - [items, project.id], - ); - const visible = useMemo( - () => applySort(applyFilters(projectTasks, filters), sort), - [projectTasks, filters, sort], - ); - - const openCount = projectTasks.filter( - (t) => t.disposition === "NEXT", - ).length; - - return ( -
- {/* Project header */} -
-
- - - Project - - - {project.hasNextAction || openCount > 0 ? ( - - - {openCount} next action{openCount === 1 ? "" : "s"} - - ) : ( - - - No next action - - )} - - setModePersist("list")} - icon={LayoutList} - label="List" - /> - setModePersist("board")} - icon={Columns3} - label="Board" - /> - -
-

- {project.outcome} -

- {project.purpose && ( -

{project.purpose}

- )} - {isSynced && !providerStages && ( -

- - Sync the workspace to load this project's ClickUp stages. -

- )} -
- - {projectTasks.length > 0 && } - - {visible.length === 0 ? ( -
- -

- {projectTasks.length === 0 - ? "No tasks in this project yet." - : "No tasks match your filters."} -

-
- ) : mode === "board" ? ( -
- -
- ) : ( - - )} -
- ); -} - -function ModeButton({ - active, - onClick, - icon: Icon, - label, -}: { - active: boolean; - onClick: () => void; - icon: typeof LayoutList; - label: string; -}) { - return ( - - ); -} diff --git a/workbench/control_plane/src/app/tasks/components/ProjectsList.tsx b/workbench/control_plane/src/app/tasks/components/ProjectsList.tsx deleted file mode 100644 index 8f4fb17c3..000000000 --- a/workbench/control_plane/src/app/tasks/components/ProjectsList.tsx +++ /dev/null @@ -1,572 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { - FolderKanban, - AlertTriangle, - ChevronRight, - HardDrive, - Cloud, - Folder, - Boxes, - Plus, - Loader2, -} from "lucide-react"; -import { useTaskStore } from "../lib/taskStore"; -import { GtdProject } from "../lib/types"; -import type { LocalFolder, LocalSpace } from "../lib/api"; -import type { TaskAccount } from "../lib/api"; - -// The Projects view — a navigable hierarchy that mirrors the PM tool: -// ClickUp → Space → Folder → List(=Project) → Task → Subtask -// Local → Space → Folder → Project → Task → Subtask -// Two clearly-separated sections (ClickUp vs Local), each an expandable tree. -// Tasks + subtasks lazy-load from the store when a project node is expanded. - -export function ProjectsList() { - const accounts = useTaskStore((s) => s.accounts); - const localHierarchy = useTaskStore((s) => s.localHierarchy); - const projects = useTaskStore((s) => s.projects); - const backend = useTaskStore((s) => s.backend); - - // Only ACTIVE (or unset-status) projects clutter-free. - const activeProjects = useMemo( - () => projects.filter((p) => p.status === "ACTIVE"), - [projects], - ); - const syncedAccounts = accounts; - const hasLocal = - (localHierarchy?.spaces.length ?? 0) > 0 || - activeProjects.some((p) => p.source === "LOCAL"); - - return ( -
-
-
- -

Projects

- - {activeProjects.length} active - -
-

- Spaces, folders, projects, tasks, and subtasks — local and your - connected tools, side by side. -

-
- -
-
- {/* ── Connected tools (ClickUp) ── */} - {syncedAccounts.map((acct) => ( - - ))} - - {/* ── Local ── */} - - - {!hasLocal && syncedAccounts.length === 0 && ( -
- -

- No projects yet. Connect a workspace or create a local space. -

-
- )} -
-
-
- ); -} - -// ── Section shells ────────────────────────────────────────────────────────── - -function SectionHeader({ - icon: Icon, - label, - tone, - count, -}: { - icon: typeof HardDrive; - label: string; - tone: "local" | "synced"; - count?: number; -}) { - return ( -
- - - {label} - - {count != null && ( - - {count} - - )} -
- ); -} - -/** A connected workspace (ClickUp): its spaces → folders → lists(projects). */ -function AccountSection({ - account, - projects, -}: { - account: TaskAccount; - projects: GtdProject[]; -}) { - // Mirrored list → the GtdProject that mirrors it (by providerRef). - const byRef = useMemo(() => { - const m = new Map(); - for (const p of projects) - if (p.source === "SYNCED" && p.providerRef) - m.set(p.providerRef, p); - return m; - }, [projects]); - - const spaceCount = account.hierarchy.length; - - return ( -
-
- -
-
- {account.hierarchy.length === 0 ? ( -

- No spaces synced — refresh the workspace schema. -

- ) : ( - account.hierarchy.map((space) => ( - - {/* folderless lists directly on the space */} - {space.lists.map((l) => ( - - ))} - {space.folders.map((folder) => ( - - {folder.lists.map((l) => ( - - ))} - {folder.lists.length === 0 && ( - - )} - - ))} - {space.lists.length === 0 && space.folders.length === 0 && ( - - )} - - )) - )} -
-
- ); -} - -/** The Local tree: our spaces → folders → projects, plus an ungrouped bucket. */ -function LocalSection({ - hierarchy, - projects, - loading, -}: { - hierarchy: ReturnType["localHierarchy"]; - projects: GtdProject[]; - loading: boolean; -}) { - const createLocalSpace = useTaskStore((s) => s.createLocalSpace); - const createLocalFolder = useTaskStore((s) => s.createLocalFolder); - const createLocalProject = useTaskStore((s) => s.createLocalProject); - - const spaces: LocalSpace[] = hierarchy?.spaces ?? []; - // Local projects that aren't in the hierarchy payload (older rows) still show - // via the flat projects list under an "Ungrouped" bucket. - const localProjects = projects.filter((p) => p.source === "LOCAL"); - const foldersBySpace = useMemo(() => { - const m = new Map(); - for (const f of hierarchy?.folders ?? []) { - const arr = m.get(f.spaceId) ?? []; - arr.push(f); - m.set(f.spaceId, arr); - } - return m; - }, [hierarchy?.folders]); - - const projectsIn = (spaceId?: string, folderId?: string) => - localProjects.filter((p) => - folderId - ? p.folderId === folderId - : spaceId - ? p.spaceId === spaceId && !p.folderId - : !p.spaceId && !p.folderId, - ); - - const ungrouped = projectsIn(undefined, undefined); - - return ( -
-
- - void createLocalSpace(name)} - /> -
-
- {loading ? ( -
- Loading… -
- ) : spaces.length === 0 && ungrouped.length === 0 ? ( -

- No local spaces yet. Add one to organise your own projects. -

- ) : ( - <> - {spaces.map((space) => { - const spFolders = foldersBySpace.get(space.id) ?? []; - return ( - void createLocalFolder(space.id, name)} - /> - } - > - {projectsIn(space.id, undefined).map((p) => ( - - ))} - {spFolders.map((folder) => ( - - {projectsIn(space.id, folder.id).map((p) => ( - - ))} - {projectsIn(space.id, folder.id).length === 0 && ( - - )} - - ))} - - void createLocalProject({ outcome, spaceId: space.id }) - } - /> - - ); - })} - - {ungrouped.length > 0 && ( - - {ungrouped.map((p) => ( - - ))} - - )} - - void createLocalProject({ outcome })} - placeholder="New project (no space)…" - /> - - )} -
-
- ); -} - -// ── Tree primitives ───────────────────────────────────────────────────────── - -const INDENT = 14; - -function TreeNode({ - label, - icon: Icon, - depth, - children, - defaultOpen = false, - trailing, -}: { - label: string; - icon: typeof Folder; - depth: number; - children: React.ReactNode; - defaultOpen?: boolean; - trailing?: React.ReactNode; -}) { - const [open, setOpen] = useState(defaultOpen); - return ( -
-
- - {trailing && ( - - {trailing} - - )} -
- {open &&
{children}
} -
- ); -} - -/** A project (ClickUp list or local project) — a SELECTABLE leaf. Clicking it - * selects the project; its tasks render in the right-hand pane (list/board), - * not nested here. Not-yet-mirrored ClickUp lists are shown muted + disabled. */ -function ProjectNode({ - project, - fallbackName, - depth, -}: { - project?: GtdProject; - fallbackName?: string; - depth: number; -}) { - const items = useTaskStore((s) => s.items); - const selectProject = useTaskStore((s) => s.selectProject); - const selectedProjectId = useTaskStore((s) => s.selectedProjectId); - - const name = project?.outcome ?? fallbackName ?? "Untitled"; - // Not-yet-mirrored ClickUp lists have no local project row — show them muted. - const notMirrored = !project; - const selected = !!project && selectedProjectId === project.id; - - const openTasks = useMemo( - () => - project - ? items.filter( - (i) => - i.projectId === project.id && - !i.parentItemId && - i.disposition === "NEXT", - ).length - : 0, - [items, project], - ); - - return ( - - ); -} - -function EmptyLeaf({ depth, text }: { depth: number; text: string }) { - return ( -

- {text} -

- ); -} - -// ── Inline creators ───────────────────────────────────────────────────────── - -function AddInline({ - label, - onAdd, -}: { - label: string; - onAdd: (name: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [name, setName] = useState(""); - const commit = () => { - const t = name.trim(); - if (t) onAdd(t); - setName(""); - setEditing(false); - }; - if (!editing) { - return ( - - ); - } - return ( - setName(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); commit(); } - if (e.key === "Escape") { setName(""); setEditing(false); } - }} - placeholder={`New ${label.toLowerCase()}…`} - className="w-32 rounded border border-primary/40 bg-background px-1.5 py-0.5 text-[11px] text-foreground focus:outline-none" - /> - ); -} - -function AddProjectRow({ - depth, - onAdd, - placeholder = "New project…", -}: { - depth: number; - onAdd: (outcome: string) => void; - placeholder?: string; -}) { - const [name, setName] = useState(""); - const commit = () => { - const t = name.trim(); - if (!t) return; - onAdd(t); - setName(""); - }; - return ( -
- - setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); commit(); } - }} - placeholder={placeholder} - className="min-w-0 flex-1 bg-transparent text-[12px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none" - /> - {name.trim() && ( - - )} -
- ); -} diff --git a/workbench/control_plane/src/app/tasks/lib/taskStore.ts b/workbench/control_plane/src/app/tasks/lib/taskStore.ts index 21d1aba81..9d739764f 100644 --- a/workbench/control_plane/src/app/tasks/lib/taskStore.ts +++ b/workbench/control_plane/src/app/tasks/lib/taskStore.ts @@ -442,7 +442,6 @@ interface TaskState { /** when drilled into a single @context under Next Actions */ selectedContext: string | null; selectedItemId: string | null; - selectedProjectId: string | null; /** A task opened FULL-PAGE (focused overlay) — the ClickUp/Linear-style * maximized view over the same editable detail. null = closed. */ focusedItemId: string | null; @@ -515,7 +514,6 @@ interface TaskState { selectView: (view: ViewKey) => void; selectContext: (context: string | null) => void; selectItem: (id: string | null) => void; - selectProject: (id: string | null) => void; /** Capture a new inbox item (frictionless quick-add). */ capture: (title: string, attachments?: import("./types").TaskAttachment[], dates?: import("./api").CaptureDates) => void; /** Capture many items at once (mind sweep) — one per non-empty line. */ @@ -796,7 +794,6 @@ export const useTaskStore = create((set, get) => ({ groupBy: "", setGroupBy: (g) => set({ groupBy: g }), selectedItemId: null, - selectedProjectId: null, lastCaptureIds: [], quickCaptureOpen: false, quickCaptureMode: "single", @@ -824,7 +821,6 @@ export const useTaskStore = create((set, get) => ({ selectedView: view, selectedContext: null, selectedItemId: null, - selectedProjectId: null, // A search/filter is scoped to the view you set it in — reset on nav so a // stale query doesn't silently hide items in the next view. filters: DEFAULT_FILTERS, @@ -840,9 +836,6 @@ export const useTaskStore = create((set, get) => ({ selectItem: (id) => set({ selectedItemId: id }), - selectProject: (id) => - set({ selectedView: "projects", selectedProjectId: id, selectedItemId: null }), - capture: (title, attachments, dates) => { const t = title.trim(); if (!t) return; diff --git a/workbench/control_plane/src/app/tasks/lib/types.ts b/workbench/control_plane/src/app/tasks/lib/types.ts index 24d9bafd8..f31ad77f8 100644 --- a/workbench/control_plane/src/app/tasks/lib/types.ts +++ b/workbench/control_plane/src/app/tasks/lib/types.ts @@ -228,15 +228,24 @@ export interface Target { accountId?: string; } -/** The left-rail views. */ +/** The left-rail views. + * + * **Personal only.** `projects` and `people` were removed 2026-08-06 (owner + * decision): this app manages the tasks that are *yours*. The company's + * departments, projects and team tasks live in `/projects`, and the directory + * lives in `/people` — one surface each, rather than a second half-copy of both + * behind a task manager. + * + * `GtdProject` itself survives: a task still belongs to a project and the cards + * still name it. What went is the *browsing* surface, which was the ClickUp + * Space → Folder → List hierarchy — precisely what `/projects` now owns. + */ export type ViewKey = | "inbox" | "next" | "priority" | "waiting" | "calendar" - | "projects" - | "people" | "someday" | "reference" | "done" diff --git a/workbench/control_plane/src/app/tasks/page.tsx b/workbench/control_plane/src/app/tasks/page.tsx index 55485ba95..ae2994b88 100644 --- a/workbench/control_plane/src/app/tasks/page.tsx +++ b/workbench/control_plane/src/app/tasks/page.tsx @@ -1,16 +1,14 @@ "use client"; import { useEffect, useState } from "react"; -import { PanelLeft, Plus, ArrowLeft, Sparkles } from "lucide-react"; +import { PanelLeft, Plus, Sparkles } from "lucide-react"; import { useViewMode } from "@/components/ViewModeProvider"; import { useMobileDrawer } from "@/components/AppShell"; import { useTaskStore } from "./lib/taskStore"; import { ListsSidebar } from "./components/ListsSidebar"; import { CaptureBar } from "./components/CaptureBar"; import { ItemList } from "./components/ItemList"; -import { ItemDetail } from "./components/ItemDetail"; import { AssistantRail } from "./components/AssistantRail"; -import { PeopleView } from "./components/PeopleView"; import { InboxView } from "./components/InboxView"; import { EngageView } from "./components/EngageView"; import { CalendarView } from "./components/CalendarView"; @@ -33,8 +31,6 @@ export default function TasksPage() { const { isMobile } = useViewMode(); const { open: openDrawer, close: closeDrawer } = useMobileDrawer(); const selectedView = useTaskStore((s) => s.selectedView); - const selectedProjectId = useTaskStore((s) => s.selectedProjectId); - const selectProject = useTaskStore((s) => s.selectProject); const selectView = useTaskStore((s) => s.selectView); const openQuickCapture = useTaskStore((s) => s.openQuickCapture); const quickCaptureOpen = useTaskStore((s) => s.quickCaptureOpen); @@ -45,9 +41,7 @@ export default function TasksPage() { // not an always-on right rail. const [assistantOpen, setAssistantOpen] = useState(false); const isInbox = selectedView === "inbox"; - const isProjects = selectedView === "projects"; const isEngage = selectedView === "engage"; - const isPeople = selectedView === "people"; const isCalendar = selectedView === "calendar"; // Load live data from the gateway once; stays on the bundled mock data when @@ -127,21 +121,6 @@ export default function TasksPage() { ) : isCalendar ? ( - ) : isProjects && selectedProjectId ? ( - // A selected project → full-screen roll-up with a Back affordance. -
- -
- -
-
) : ( )} @@ -226,31 +205,12 @@ export default function TasksPage() {
- ) : isPeople ? ( - /* People: the HR roster — full-width management surface (like Engage). */ -
- -
) : isCalendar ? ( /* Calendar: the timeboxing surface — full-width day/week/month grid (spec: calendar_timeboxing.md), no list/detail split. */
- ) : isProjects ? ( - /* Projects keep the list + project-detail split (a project isn't a - task card — its detail is a roll-up of its actions). */ - <> -
- -
- -
-
-
- -
- ) : ( /* Task views (Next/Waiting/Someday/Calendar): a full-width list/board. Clicking a task opens it as a pop-up card (TaskFocusModal, diff --git a/workbench/control_plane/src/lib/access.ts b/workbench/control_plane/src/lib/access.ts index 538fd9127..dfe51700f 100644 --- a/workbench/control_plane/src/lib/access.ts +++ b/workbench/control_plane/src/lib/access.ts @@ -81,6 +81,7 @@ const HREF_FEATURES: ReadonlyArray<[string, string]> = [ // grant model in the API is the real boundary and this is the courtesy half // (specs/project_management_app.md §5). ["/projects", "projects"], + ["/people", "people"], ["/dashboard", "dashboard"], ["/observability", "observability"], ["/artifacts", "artifacts"], diff --git a/workbench/control_plane/src/lib/centers.ts b/workbench/control_plane/src/lib/centers.ts index 50f125fa3..974fe71ca 100644 --- a/workbench/control_plane/src/lib/centers.ts +++ b/workbench/control_plane/src/lib/centers.ts @@ -303,7 +303,11 @@ export const CENTERS: Center[] = [ label: "Directory & org chart", note: "Members and groups — the same org_group records that scope the Centers", icon: "Network", - status: "planned", + // WS-28b: the read view WS-13 asked for. Live at /people — one app, + // not a People-Center fork of one, the same (app + scope) rule the + // Projects entries follow. + status: "live", + href: "/people", }, { label: "Onboarding", diff --git a/workbench/control_plane/src/lib/nav.ts b/workbench/control_plane/src/lib/nav.ts index f49287fa3..0cefcec62 100644 --- a/workbench/control_plane/src/lib/nav.ts +++ b/workbench/control_plane/src/lib/nav.ts @@ -147,6 +147,17 @@ export const NAV_SECTIONS: NavSection[] = [ note: "Departments, projects and team tasks", feature: "projects", }, + // The people behind the work, next to it. Its own feature slug rather + // than riding `tasks`: a manager who needs the org chart and the + // assignee picker should not have to be handed the personal GTD task + // manager to get them (people_center_app.md §6). + { + href: "/people", + label: "People", + icon: "Users", + note: "Directory, skills and org chart", + feature: "people", + }, ], },