Skip to content

feat: Grid org overlay — department zones + reporting lines (abilityai/trinity-enterprise#305) - #1918

Open
vybe wants to merge 2 commits into
devfrom
feature/305-grid-org-overlay
Open

feat: Grid org overlay — department zones + reporting lines (abilityai/trinity-enterprise#305)#1918
vybe wants to merge 2 commits into
devfrom
feature/305-grid-org-overlay

Conversation

@vybe

@vybe vybe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Department zones + reporting lines on the Dashboard Grid (OSS-core, per decision on the issue): departments are dept-<name> tags rendered as derived hull frames around member tiles; reporting lines are reports-to-<agent> tags on the REPORT agent, rendered as manager→report arrows. No schema change — namespaced tags only, with the prefix pair mirrored between db/tags.py and utils/gridOrg.js.
  • Interactions: bottom connect port (drag from manager onto report, live "X will report to Y" pill, undo toast), click-line removal with undo, drop-into-zone dept reassignment (re-validated at drop), zone-header block move with spring-back on invalid targets, "Group by dept" dense arrange, zone-aware Tidy and newcomer placement, "New department" flow (named validation + click-to-assign mode), per-user Zones/Lines toggles.
  • Guardrails & integrity (from the two independent plan reviews): org namespaces are human-only — the tags router rejects agent-principal writes (mirrors the feat: system-emitted agent.task.completed/failed events at execution terminal (async caller report-back) #1578 reserved-namespace pattern); dept assignment is an atomic PUT /tags set-list; every tag mutation broadcasts agent_tags_changed so all open browsers converge (verified live: an API write updated an open Grid with no reload); agent rename rewrites reports-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 (zero dept-* fleet-wide) and renders read-only zones.
  • Spacing contract: lattice gaps (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 in Dashboard.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 — passes
  • Live verification on a running stack: zones/ribbons/arrows render (light + dark), connect drag writes the tag, agent_tags_changed broadcast converged an open browser with no reload
  • e2e: npx playwright test grid-org-overlay (runs in CI via the ui label)

Refs abilityai/trinity-enterprise#305 (private tracker — closed manually at release)

🤖 Generated with Claude Code

Eugene Vyborov and others added 2 commits July 30, 2026 19:39
…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>
@vybe
vybe requested a review from AndriiPasternak31 as a code owner July 31, 2026 11:37
@vybe vybe added the ui PR touches the frontend UI — triggers Playwright e2e tests label Jul 31, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:254 wires this to manager, not filtered_manager.
  • main.py:187,200ConnectionManager registers and publishes unconditionally at SCOPE_ALL.
  • services/event_bus.py:110-112if slot.scope == SCOPE_ALL: return event_scope == SCOPE_ALL. accessible_agents is consulted only in the SCOPE_SCOPED arm below it, so there is no access filter on this path.
  • main.py:1117-1122/ws needs 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-85db/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_refs collision handling is correct. I initially flagged the delete at db/tags.py:57-63 as a cross-tenant data-loss primitive, then refuted it: the holders subquery scopes the delete to agents that already hold old_tag, so only a row that would duplicate anyway is removed — and the following UPDATE restores it. The live-collision case is unreachable because rename_agent's taken check rejects the rename first, so new_tag can only collide with a stale ref from a purged predecessor, exactly as your docstring says.
  • No guard/writer normalization gap. _guard_org_namespace and db/tags.py:set_agent_tags both normalize t.lower().strip() with the same falsy filter, so there's no DEPT-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_tags is registered in AGENT_REFS (db/agent_cleanup.py:160, Policy.CASCADE), so cascade_rename already re-keys the agent's own tag rows. rename_reports_to_refs rewrites a tag value on other agents' rows — structurally a different operation, correctly kept as a separate call, and it can't fold into AGENT_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 (greps rename_agent's body for cascade_rename(, and for any update(<table>)). Taking dev's side wholesale drops the hook and fails your own test_305_org_tag_integrity.py:119-145. The correct resolution satisfies both — the added lines contain no update(, since that lives in db/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.

dolho added a commit that referenced this pull request Aug 3, 2026
`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>
dolho added a commit that referenced this pull request Aug 5, 2026
`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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants