feat: Grid org overlay — department zones + reporting lines (abilityai/trinity-enterprise#305) - #1918
feat: Grid org overlay — department zones + reporting lines (abilityai/trinity-enterprise#305)#1918vybe wants to merge 2 commits into
Conversation
…e (abilityai/trinity-enterprise#305) Department zones (hull model over dept-* tags, plain-tag fallback), vertical reporting lines (reports-to-* tags, bottom ports, drag-to-create/click-to- remove/hover-chain), drop-into-zone assign, zone-header block move with validity + spring-back, Group-by-dept dense arrange, chrome-in-gap spacing contract (GAP_X 40 / GAP_Y 50 vs frame overhangs 22/10/34/10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ai/trinity-enterprise#305) Departments = dept-* tags rendered as derived hull zones; reporting lines = reports-to-* tags on the report agent, rendered manager→report. No schema change. Frontend: gridOrg pure module + useOrgOverlay composable (connect-port drag with live direction pill, drop-to-assign re-validated at drop with undo toast, zone-header block move, Group-by-dept arrange, zone-aware Tidy + newcomer placement, New-department assign mode, bootstrap read-only fallback, stable hash palette slots themed light/dark, rAF-throttled drags). Backend: org namespaces are human-only (agent-principal writes rejected, #1578 pattern); tag mutations broadcast agent_tags_changed; rename rewrites reports-to-* values in-transaction (PK-collision-safe); hard purge deletes dangling refs. Generic tag surfaces hide org namespaces (isOrgTag); the AgentDetail editor shows all. Tests: vitest suite for the pure modules (incl. the zone-chrome/gap spacing contract pin) wired into frontend-build.yml; pytest for rename collision, purge sweep, and the namespace guard; Playwright e2e smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Resolve by running |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Ran /review + /cso --diff, scoped to the security surface and the rebase plan (CI can't run while the branch is CONFLICTING).
Because the branch is 89 commits behind, I did a local trial merge onto dev first so these findings are against the merged form, not the stale base. routers/tags.py and main.py auto-merge clean, so everything below lands unchanged after the rebase.
Really nice feature — the guardrails you added from the plan reviews (human-only namespaces, atomic set-list PUT, PK-collision-safe rename) are the right instincts, and the rename hook in particular is subtle work. Two things to fix before this can land.
1. agent_tags_changed puts the org chart on the unfiltered SCOPE_ALL socket
src/backend/routers/tags.py:41-46:
await manager.broadcast(json.dumps(
{"type": "agent_tags_changed", "agent_name": agent_name, "tags": tags}))Chain, verified end-to-end:
main.py:254wires this tomanager, notfiltered_manager.main.py:187,200—ConnectionManagerregisters and publishes unconditionally atSCOPE_ALL.services/event_bus.py:110-112—if slot.scope == SCOPE_ALL: return event_scope == SCOPE_ALL.accessible_agentsis consulted only in theSCOPE_SCOPEDarm below it, so there is no access filter on this path.main.py:1117-1122—/wsneeds only a valid ticket, and minting a ticket needs only a JWT. Any role qualifies.
Net: a user-role account shared exactly one agent can idle on /ws and reconstruct every tenant's department membership and reports-to-<manager> edges — including agents it can't see in GET /api/agents.
This is the class tests/unit/test_918_report_broadcast.py exists to prevent; its docstring states the rule verbatim ("/ws is SCOPE_ALL and unfiltered … ONLY trigger metadata"), and this payload fails that test's assertion shape by construction.
Fix — two paired edits, not one. Thin the payload to {type, agent_name} and switch stores/network.js:753-762 handleAgentTagsChanged to the _refetchAgentTags you already built at :774-784 (access-controlled GET /api/agents/{name}/tags, already called on the error path at :801). Doing only the backend half silently breaks the live convergence the PR body verified. Worth a test_305_* broadcast guard mirroring test_918_report_broadcast.py so it can't regress.
2. Thinning the broadcast doesn't fully close it — GET /api/tags is unscoped
routers/tags.py:78-85 → db/tags.py:list_all_tags is a bare GROUP BY tag over the whole table, gated only on Depends(get_current_user) — no owner, sharing, or admin scoping at all.
The endpoint predates this PR, but this PR is what puts reports-to-<manager-agent-name> and dept-<name> into it. After the broadcast is thinned, every manager agent name and department name + headcount stays readable by any authenticated user over REST.
The edges aren't exposed — get_agents_by_tag exists in db/tags.py but has no route — which is the only reason I'd call this medium rather than high. Still needs a decision: scope list_all_tags for non-admins (at minimum filter ORG_TAG_PREFIXES), or explicitly document departments and reporting lines as fleet-public metadata. Right now it's neither.
Also worth a look (non-blocking)
The human-only invariant holds at one of two writers. _guard_org_namespace covers all three tag routes correctly, but services/system_service.py:616 calls db.set_agent_tags() directly. routers/systems.py:29 gates deploy_system on require_role("creator"), which an agent-scoped key satisfies via Invariant #8 ("resolves to its owner carrying the owner's role") on any creator/admin-owned install — and the manifest tag validator (system_service.py:186, ^[a-z0-9][a-z0-9-]*[a-z0-9]$) accepts both org prefixes. Bounded, because :388 suffixes name collisions so it can only tag agents the manifest creates — it can't re-parent an existing one. But a fabricated node hanging under a real manager is the exact threat the guard's docstring names. That path also emits no agent_tags_changed, so convergence has a hole there too. Either extend the guard, or soften the claim in architecture.md:263 / requirements/core-agent.md:296 to "the tags API is human-only".
PUT /agents/{name}/tags has no validation. db_models.py:704-706 is a bare tags: List[str] — no length cap, charset check, or list-size cap, while the sibling POST route validates all three (tags.py:138-141). The overlay writes exclusively via PUT, and gridOrg.js:28-29 enforces a 50-char cap client-side "mirroring the tags router's cap" — a cap that doesn't exist on the path it uses. No injection or XSS reachable from it (Core is parameterized, no v-html in the new components), so it's robustness rather than security.
e2e/grid-org-overlay.spec.js:51-54 afterEach blind-PUTs [] to trinity-system, wiping every pre-existing tag on the shared system agent rather than only test-added ones.
Two things I checked and want to explicitly clear
rename_reports_to_refscollision handling is correct. I initially flagged the delete atdb/tags.py:57-63as a cross-tenant data-loss primitive, then refuted it: theholderssubquery scopes the delete to agents that already holdold_tag, so only a row that would duplicate anyway is removed — and the followingUPDATErestores it. The live-collision case is unreachable becauserename_agent'stakencheck rejects the rename first, sonew_tagcan only collide with a stale ref from a purged predecessor, exactly as your docstring says.- No guard/writer normalization gap.
_guard_org_namespaceanddb/tags.py:set_agent_tagsboth normalizet.lower().strip()with the same falsy filter, so there's noDEPT-x/ leading-whitespace bypass. The PUT guard checking the symmetric difference is also right — an agent can still rewrite its own plain tags without tripping on untouched org tags.
Rebase plan
git merge-tree against current dev gives exactly 6 conflicts:
| File | Kind | Resolution |
|---|---|---|
views/Agents.vue |
modify/delete | dc40ac50 deleted it; re-home the org-tag filter into components/AgentListPanel.vue (getAgentTags ~:960, chips ~:411) |
db/agent_settings/metadata.py |
semantic | see below |
stores/network.js |
1-line import | keep both (agentDisplayName + isOrgTag) — confirmed in the trial merge |
views/Dashboard.vue |
1-line import | keep both |
requirements/core-agent.md |
positional | renumber to §9.11 (dev added §9.9/§9.10) |
package-lock.json |
lockfile | regenerate with npm install, don't hand-merge |
metadata.py is the one to be careful with. Your hook at :295-303 sits inside the ~180-line hand-written cascade that #1819 (8efe943c) deleted in favour of cascade_rename(conn, old_name, new_name). Correct resolution: take dev's cascade_rename(…), then re-add just the two hook lines after it.
I verified this is safe rather than assuming it:
- Nothing is lost —
agent_tagsis registered inAGENT_REFS(db/agent_cleanup.py:160,Policy.CASCADE), socascade_renamealready re-keys the agent's own tag rows.rename_reports_to_refsrewrites a tag value on other agents' rows — structurally a different operation, correctly kept as a separate call, and it can't fold intoAGENT_REFS. - Both wrong resolutions are test-fenced, and the correct one passes both. Taking your side fails dev's
test_1819_rename_cascade_parity.py:229-253(grepsrename_agent's body forcascade_rename(, and for anyupdate(<table>)). Taking dev's side wholesale drops the hook and fails your owntest_305_org_tag_integrity.py:119-145. The correct resolution satisfies both — the added lines contain noupdate(, since that lives indb/tags.py, outside the scanned body.
One decision the rebase forces that's worth calling out: the PR passes :agents="gridAgents" (owner filter only), but dev deleted gridAgents and passes :agents="visibleAgents", additionally narrowed by ent#261's / type-to-filter. Git resolves that silently, but the overlay derives its whole world from that prop (FleetGrid.vue:461-462) — filtering out the only dept-* agent flips the fleet into bootstrap mode and reporting lines vanish as you type. The overlay probably wants the unfiltered roster.
Full write-ups are in my local /review + /cso --diff reports — happy to paste either in full, or to take the rebase myself if you'd rather not hand-resolve metadata.py.
`backend-unit-nightly.yml` merges each open PR into `dev` and runs the suite on the result. It shallow-fetched BOTH sides — `fetch-depth: 1` on the checkout and `--depth=1` on `pull/N/head` — so the two single-commit histories shared no ancestor and git exited `fatal: refusing to merge unrelated histories`. The step's `if ! git merge` recorded that as `merge_conflict=true`. The result was a detector whose output did not depend on its input: every open PR was flagged (9/9 at the time of the report, 8 of them MERGEABLE per GitHub), so a real conflict read exactly like the false ones, `steps.merge` short-circuited before the suite ran, and `regression` defaulted to false. The job's entire purpose — catching regressions that only appear once a PR is merged into dev — had not executed for any PR, while its sticky comment told every author to run an unnecessary `git merge dev`. Fix: `fetch-depth: 0` on the base checkout and no `--depth` on the PR-head fetch. Deliberately NOT `--allow-unrelated-histories`, which would make the merge succeed by grafting two unrelated trees — the suite would then run against a worktree that is not the real merge result. Also hardened the verdict itself: `merge_conflict=true` now requires actual unmerged paths (`git diff --name-only --diff-filter=U`). A non-zero `git merge` is not proof of a content conflict — that conflation is what made this survivable for so long — so any other git failure now fails the job loudly instead of posting a confident wrong answer to every PR. Verified against the real repo, replicating the workflow's exact steps: - before, on PR #1950 (MERGEABLE): `fatal: refusing to merge unrelated histories`, exit 128 -> merge_conflict=true - after, PR #1950: merge succeeds -> the suite would run - after, PR #1918 (CONFLICTING): fails with real content conflicts in 4 files — a genuine conflict is still detected `tests/unit/test_1941_nightly_merge_depth.py` pins all of it; 3 of its 5 checks fail against the pre-fix workflow. It strips comment lines before matching, because the fix's own comment says "no --depth" and a naive substring search matches that and passes while the command is shallow again. Related to #1941 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`backend-unit-nightly.yml` merges each open PR into `dev` and runs the suite on the result. It shallow-fetched BOTH sides — `fetch-depth: 1` on the checkout and `--depth=1` on `pull/N/head` — so the two single-commit histories shared no ancestor and git exited `fatal: refusing to merge unrelated histories`. The step's `if ! git merge` recorded that as `merge_conflict=true`. The result was a detector whose output did not depend on its input: every open PR was flagged (9/9 at the time of the report, 8 of them MERGEABLE per GitHub), so a real conflict read exactly like the false ones, `steps.merge` short-circuited before the suite ran, and `regression` defaulted to false. The job's entire purpose — catching regressions that only appear once a PR is merged into dev — had not executed for any PR, while its sticky comment told every author to run an unnecessary `git merge dev`. Fix: `fetch-depth: 0` on the base checkout and no `--depth` on the PR-head fetch. Deliberately NOT `--allow-unrelated-histories`, which would make the merge succeed by grafting two unrelated trees — the suite would then run against a worktree that is not the real merge result. Also hardened the verdict itself: `merge_conflict=true` now requires actual unmerged paths (`git diff --name-only --diff-filter=U`). A non-zero `git merge` is not proof of a content conflict — that conflation is what made this survivable for so long — so any other git failure now fails the job loudly instead of posting a confident wrong answer to every PR. Verified against the real repo, replicating the workflow's exact steps: - before, on PR #1950 (MERGEABLE): `fatal: refusing to merge unrelated histories`, exit 128 -> merge_conflict=true - after, PR #1950: merge succeeds -> the suite would run - after, PR #1918 (CONFLICTING): fails with real content conflicts in 4 files — a genuine conflict is still detected `tests/unit/test_1941_nightly_merge_depth.py` pins all of it; 3 of its 5 checks fail against the pre-fix workflow. It strips comment lines before matching, because the fix's own comment says "no --depth" and a naive substring search matches that and passes while the command is shallow again. Related to #1941 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
dept-<name>tags rendered as derived hull frames around member tiles; reporting lines arereports-to-<agent>tags on the REPORT agent, rendered as manager→report arrows. No schema change — namespaced tags only, with the prefix pair mirrored betweendb/tags.pyandutils/gridOrg.js.PUT /tagsset-list; every tag mutation broadcastsagent_tags_changedso all open browsers converge (verified live: an API write updated an open Grid with no reload); agent rename rewritesreports-to-<old>values fleet-wide inside the rename transaction (PK-collision-safe — a blind UPDATE would abort the whole rename); hard purge deletes dangling refs so a reused name can't re-attach a predecessor's org chart. Plain-tag fallback runs only in bootstrap mode (zerodept-*fleet-wide) and renders read-only zones.GAP_X 40 / GAP_Y 50) absorb the zone chrome (22/10/34/10), so adjacent-row/column departments never collide and the arrange needs no spacer cells — pinned by a unit test.Changes
Frontend:
utils/gridOrg.js(new pure module),composables/useOrgOverlay.js(new),FleetGrid.vue,AgentTile.vue,stores/network.js(tag writes + WS patch),stores/fleetGrid.js,utils/gridLayout.js, org-tag hiding inDashboard.vue/Agents.vue/SystemViewEditor.vue.Backend:
routers/tags.py(namespace guard + broadcast),db/tags.py(ref ops + prefix constants),db/agent_settings/metadata.py(rename hook),db/agent_cleanup.py(purge hook),main.py(WS injection).Tests/docs: vitest setup + 27 unit tests (wired into
frontend-build.yml), 10 pytest tests, Playwright e2e smoke, requirements/feature-flow/architecture updates.Test Plan
npm run test:unit— 27 passed (incl. zone-chrome/gap contract pin, arrowhead emission, arrange/tidy collision-freedom)pytest tests/unit/test_305_org_tag_integrity.py tests/unit/test_1664_renamed_agent_volume_safety.py tests/unit/test_agent_cleanup_parity.py— 52 passed (rename collision case, purge sweep, namespace guard, no rename/cleanup regressions)npm run check:tokens— passesagent_tags_changedbroadcast converged an open browser with no reloadnpx playwright test grid-org-overlay(runs in CI via theuilabel)Refs abilityai/trinity-enterprise#305 (private tracker — closed manually at release)
🤖 Generated with Claude Code