Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
360b287
feat(llc/api): add read-only GET /companies/{id}/org-chart endpoint (…
mrveiss Jun 10, 2026
cefaf8c
fix(llc/frontend): wire OrgChart to real org-chart + controls routes …
mrveiss Jun 10, 2026
a46ffc7
fix(llc/frontend): wire HeartbeatMonitor agents query to /api/llc/age…
mrveiss Jun 10, 2026
7023a52
fix(llc/frontend): build company hierarchy from real routes (#9861)
mrveiss Jun 10, 2026
e91e019
fix(llc/frontend): build goal tree from flat /api/llc/goals (#9861)
mrveiss Jun 10, 2026
3d4f81b
fix(llc/frontend): feature-flag off backlog reorder + AC suggestion (…
mrveiss Jun 10, 2026
cfdacd2
fix(llc/frontend): resolve company context on the dashboard landing (…
mrveiss Jun 10, 2026
685fbc0
fix(llc/frontend): use explicit pause/resume literals in OrgChart con…
mrveiss Jun 10, 2026
aba2248
fix(llc/frontend): inline controls pause/resume paths to satisfy audi…
mrveiss Jun 10, 2026
6022b27
test(llc): e2e test for the core LLC loop (#9861)
mrveiss Jun 10, 2026
e9f050d
ci(llc): blocking LLC contract gate — scoped wiring audit + e2e (#9861)
mrveiss Jun 10, 2026
d50f170
docs(llc): ship report — rewired paths, flagged features, e2e, gate (…
mrveiss Jun 10, 2026
ee8842c
Auto-fix: code formatting
github-actions[bot] Jun 10, 2026
f959ab0
Merge branch 'Dev_new_gui' into llc-ship
mrveiss Jun 10, 2026
b2a839d
Merge branch 'Dev_new_gui' into llc-ship
mrveiss Jun 10, 2026
d7ab23c
fix(llc): address review findings on org-chart + dashboard (#9861)
mrveiss Jun 10, 2026
5e6de10
Auto-fix: code formatting
github-actions[bot] Jun 10, 2026
50f90ae
Merge branch 'Dev_new_gui' into llc-ship
mrveiss Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/llc-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copyright 2025-2026 mrveiss
# SPDX-License-Identifier: Apache-2.0
#
# LLC Contract Gate (GH#9861)
# Blocking gate for the LLC module:
# 1. Every /api/llc/* path the frontend calls must resolve to a real backend
# route (authoritative OpenAPI audit, scoped to /api/llc).
# 2. The core LLC loop e2e test must pass.
#
# Unlike the repo-wide api-wiring.yml (non-blocking, has tracked false
# positives elsewhere), this gate is scoped to LLC and IS blocking — the LLC
# surface was brought to zero unwired calls in #9861 and must stay there.

name: LLC Contract Gate

on:
push:
branches: [ main, Dev_new_gui ]
paths: &paths
- 'autobot-backend/llc/**'
- 'autobot-backend/api/llc/**'
- 'autobot-backend/models/agent_org.py'
- 'autobot-frontend/src/views/llc/**'
- 'autobot-frontend/src/composables/llc/**'
- 'scripts/audit_api_wiring.py'
- '.github/workflows/llc-contract.yml'
pull_request:
branches: [ main, Dev_new_gui ]
paths: *paths

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
llc-wiring-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
requirements*.txt
autobot-backend/requirements*.txt

- name: Install backend dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install -r requirements-ci.txt --prefer-binary || true

- name: Audit /api/llc wiring (blocking)
run: |
set -o pipefail
export PYTHONPATH="$PWD:$PWD/autobot-backend:${PYTHONPATH:-}"
mkdir -p data logs static config
# Absolute path: --dump-openapi chdir()s into autobot-backend/.
python scripts/audit_api_wiring.py --dump-openapi "$PWD/openapi.json"
python scripts/audit_api_wiring.py \
--openapi "$PWD/openapi.json" \
--only-prefix /api/llc \
--fail-on-unwired

llc-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: |
requirements*.txt
autobot-backend/requirements*.txt

- name: Install backend dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install -r requirements-ci.txt --prefer-binary || true
# Hermetic e2e essentials (no Postgres/Redis/network needed).
python -m pip install aiosqlite pytest pytest-asyncio httpx --prefer-binary

- name: Run LLC core-loop + org-chart e2e tests (blocking)
working-directory: autobot-backend
run: |
export PYTHONPATH="$PWD/..:$PWD:${PYTHONPATH:-}"
python -m pytest llc/tests/test_llc_e2e_loop.py llc/tests/test_llc_org_chart.py -x -q
116 changes: 116 additions & 0 deletions LLC_SHIP_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<!--
Copyright 2025-2026 mrveiss
SPDX-License-Identifier: Apache-2.0
-->
# LLC Module Ship Report (#9861)

Mission: make the LLC module functional end-to-end on `Dev_new_gui`. Backend is
canonical (RULE ZERO) — frontend rewired to real routes; one read-only endpoint
added by explicit exception; missing-backend features flagged off; the core loop
proven by an e2e test; a blocking CI gate added.

## Starting state
After rebasing onto the latest `Dev_new_gui` (PR **#9862** had just landed and
already fixed 9 of the original 16 unwired LLC calls — activity, agents/status,
approvals/pending, approvals/{id}/approve→decide, budgets→budget, goals/{id}/items→tasks,
heartbeat-runs, work-items/{id}/activity, work-items/{id}/artifacts), **7 LLC
frontend calls remained unwired**. All 7 are now resolved.

## PHASE 1 — Rewired paths (old → new)

| View | Old call (404) | Resolution | Commit |
|---|---|---|---|
| `OrgChart.vue` | `GET /api/llc/org-chart` | `GET /api/llc/companies/{id}/org-chart` (**new** read-only endpoint) | cefaf8c |
| `OrgChart.vue` | `POST /api/llc/agents/{id}/{action}` | `POST /api/llc/companies/{id}/controls/agents/{id}/pause` \| `/resume` (explicit literals) | cefaf8c, 685fbc0 |
| `HeartbeatMonitor.vue` | `GET /api/llc/agents${qs}` (unresolvable literal) | `GET /api/llc/agents?company_id=…` (inlined query) → real `GET /api/llc/agents` | a46ffc7 |
| `SubCompanyTree.vue` | `GET /api/llc/companies/tree` | `GET /api/llc/companies/` (roots) + `GET /api/llc/companies/{id}/tree` per root, composed client-side | 7023a52 |
| `GoalTree.vue` | `GET /api/llc/goals/tree` | `GET /api/llc/goals?company_id=…` (flat) + client-side tree from `parent_goal_id` | e91e019 |
| `CompanyDashboard.vue` | (company context never resolved) | resolves company via `useLlcCompanyContext`; WS now uses reactive `wsUrl` | cfdacd2 |

### The one added backend endpoint (RULE ZERO exception — flagged)
`GET /api/llc/companies/{company_id}/org-chart` (`autobot-backend/llc/api/companies.py`, commit 360b287).
**Read-only composition only** — no new persistence. It joins existing models:
`agent_org_nodes` (hierarchy/title/role) + `llc_agent_budgets` (budget) + latest
`llc_heartbeat_runs` (liveness/status), and assembles a forest from the
self-referencing `reports_to` edges. Tenant access enforced via
`require_org_context`. This is the permitted read-only-composition exception to
backend-canonical wiring, as authorized in the mission brief.

### Company-context decision (resolves #9861 root blocker)
The LLC company-scoped views are reached from a top-level nav entry
(`/llc/dashboard`) that carries no `:companyId`, so the id must be resolved at
runtime. Introduced `useLlcCompanyContext` (`autobot-frontend/src/composables/llc/`)
— resolution order: **route param `:companyId` → `?company=` query → first company
from `GET /api/llc/companies/`** — mirroring the backend, which scopes to the
caller's org when `company_id` is omitted. Used by OrgChart, GoalTree, and
CompanyDashboard. No routes were moved (lower risk; no nav regressions).

## Flagged-off features (no backend exists — no dead buttons)
Gated behind `import.meta.env` flags, **off by default** (`BacklogView.vue`, commit 3d4f81b):

| Feature | Missing backend | Flag | UI when off |
|---|---|---|---|
| Backlog drag-reorder persistence | `POST /api/llc/companies/{id}/backlog/reorder` | `VITE_FEATURE_LLC_BACKLOG_REORDER` | rows non-draggable; no call emitted |
| AC suggestion | `POST /api/llc/work-items/suggest-ac` (needs an LLM-backed service) | `VITE_FEATURE_LLC_SUGGEST_AC` | "Suggest ACs" button hidden |

The unreachable calls to the missing endpoints were removed (documented
placeholders) so the wiring audit stays clean and no button 404s. Both await a
backend (tracked in #9861).

## PHASE 2 — e2e proof (definition of "working")
`autobot-backend/llc/tests/test_llc_e2e_loop.py` (+ `_e2e_harness.py`), commit 6022b27.

- **Result: `1 passed in ~2.7s`** — deterministic across repeated runs.
- **Hermetic**: httpx against the real LLC routers, real DB session (in-memory
SQLite), auth dependency-overridden. **No Postgres, no Redis, no network.**
(App path: minimal-mount of the real routers — `create_app()` can't boot
in-process because its lifespan hard-requires Postgres/Redis; documented in the
test.)
- **Loop covered**: create company → hire agent → create work item → record run +
cost → checkout → handoff-to-human → review-gate approve → work item **DONE** →
budget reflects cost (`budget_spent == 0.28`, `tokens_spent == 150000`, read
back from the DB row, not Redis).
- **Steps seeded directly via the session** (no usable public endpoint — filed as
discoveries): agent hire (`AgentOrgNode` — see #9899), per-agent budget row
(see #9901), heartbeat run row (scheduler-written by design). All **public**
loop steps go through real endpoints.

## PHASE 3 — Gate
- **LLC-scoped audit**: `scripts/audit_api_wiring.py` gained `--only-prefix` to
gate one module while pre-existing repo-wide findings stay tracked separately.
`--openapi … --only-prefix /api/llc --fail-on-unwired` → **exit 0** (0 unwired
`/api/llc` calls).
- **Blocking CI**: `.github/workflows/llc-contract.yml` — job 1 dumps the real
OpenAPI and fails on any unwired `/api/llc` call; job 2 runs the e2e test. Both
blocking (the repo-wide `api-wiring.yml` stays non-blocking for its tracked
false-positives). Uses absolute `--openapi`/`--dump-openapi` paths to avoid the
dump's chdir-into-backend cwd trap.
- **`gen:types` — deferred (documented).** `npm run gen:types` targets a **live
backend** (`http://127.0.0.1:8001/openapi.json`) and `openapi-typescript` is not
installed in this environment. Regenerating the 120k-line canonical
`src/types/generated/api.ts` from a local partial dump would produce a massive,
divergent, unreviewable diff and risk corrupting the spec. The new org-chart
type lands on the next live-backend `gen:types` (deploy/CI). The **audit** is
the binding contract gate; the touched views use minimal local interfaces for
the new shapes.

## Deferred / out of scope (filed in GitHub)
- **#9899** — `AgentOrgNode` ORM model out of sync with `agent_org_nodes` migration columns (hire endpoint INSERTs unmodelled columns).
- **#9900** — two unmounted routers flagged by the audit: `analytics_engagement.py` (consumed by the frontend → silent dead feature) + `transcripts.py`. Non-LLC.
- **#9901** — no public endpoint to provision a per-agent LLC budget.
- **#9861 (parent)** — still tracks: backlog-reorder + suggest-ac backends, org-chart budget/assigned-count enrichment.
- Non-LLC unwired calls (`/api/{p}/mcp/*`, `/api/ws*`, truncated `code-intelligence`) are the known normalizer false-positives documented in `api-wiring.yml` / tracked under #9851 — not re-filed.

## Commits (branch `llc-ship`)
```
360b287 feat(llc/api): add read-only GET /companies/{id}/org-chart endpoint
cefaf8c fix(llc/frontend): wire OrgChart to real org-chart + controls routes
a46ffc7 fix(llc/frontend): wire HeartbeatMonitor agents query
7023a52 fix(llc/frontend): build company hierarchy from real routes
e91e019 fix(llc/frontend): build goal tree from flat /api/llc/goals
3d4f81b fix(llc/frontend): feature-flag off backlog reorder + AC suggestion
cfdacd2 fix(llc/frontend): resolve company context on the dashboard landing
685fbc0 fix(llc/frontend): inline controls pause/resume paths to satisfy audit
6022b27 test(llc): e2e test for the core LLC loop
e9f050d ci(llc): blocking LLC contract gate — scoped wiring audit + e2e
```
157 changes: 157 additions & 0 deletions autobot-backend/llc/api/companies.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from api.user_management.dependencies import get_current_user, require_org_context
from autobot_shared.logging_manager import get_logger
from llc.kb.collections import KbCollectionManager
from llc.models.company import (
Expand Down Expand Up @@ -57,6 +58,7 @@
from llc.services.portability import PortabilityService
from user_management.database import get_async_session
from user_management.models.organization import Organization
from user_management.services import TenantContext

logger = get_logger(__name__)

Expand Down Expand Up @@ -465,6 +467,161 @@ class AgentSearchResult(BaseModel):
manager_name: Optional[str] = None


# ------------------------------------------------------------------
# Org chart (GH#9861) — read-only composition of existing models
# ------------------------------------------------------------------


class OrgChartNode(BaseModel):
"""One agent node in the company org chart.

Composed read-only from ``agent_org_nodes`` (hierarchy/title/role),
``llc_agent_budgets`` (budget), and the latest ``llc_heartbeat_runs`` row
(liveness/status). No new persistence is introduced.
"""

id: str
name: str
title: str
status: str # active | idle | error | paused
adapter_type: str
is_human: bool
last_heartbeat: Optional[str]
budget_spent: float
budget_total: float
assigned_item_count: int
parent_id: Optional[str]
children: List["OrgChartNode"] = []


class OrgChartResponse(BaseModel):
nodes: List[OrgChartNode]


def _heartbeat_status_to_org_status(run_status: Optional[str]) -> str:
"""Map an ``LLCRunStatus`` value onto the org-chart node status vocabulary."""
if run_status == "running":
return "active"
# LLCRunStatus failure-ish terminal states (values, not names).
if run_status in ("failed", "timeout", "interrupted"):
return "error"
# completed / cancelled / rate_limited / queued / no-run → idle
return "idle"


@router.get("/{company_id}/org-chart", response_model=OrgChartResponse)
async def get_org_chart(
company_id: uuid.UUID,
session: AsyncSession = Depends(get_async_session),
_current_user: dict = Depends(get_current_user),
ctx: TenantContext = Depends(require_org_context),
) -> OrgChartResponse:
"""Return the agent reporting hierarchy for a company (GH#9861).

Read-only composition — joins the org-hierarchy, budget, and latest
heartbeat for each agent scoped to the company, then assembles a forest
from the self-referencing ``reports_to`` edges. Tenant access is enforced
via :func:`require_org_context`.
"""
from sqlalchemy import func, select

from llc.models.budget import LLCAgentBudget
from llc.models.heartbeat_run import LLCHeartbeatRun
from models.agent_org import AgentOrgNode

cid = str(company_id)
if str(ctx.org_id) != cid and not ctx.is_platform_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

# 1. Hierarchy rows for the company.
org_rows = (
(await session.execute(select(AgentOrgNode).where(AgentOrgNode.company_id == company_id))).scalars().all()
)

# 2. Budgets keyed by agent_id.
budget_rows = (
(await session.execute(select(LLCAgentBudget).where(LLCAgentBudget.company_id == cid))).scalars().all()
)
budgets = {b.agent_id: b for b in budget_rows}

# 3. Latest heartbeat run per agent (status + liveness).
subq = (
select(
LLCHeartbeatRun.agent_id,
func.max(LLCHeartbeatRun.created_at).label("latest_at"),
)
.where(LLCHeartbeatRun.company_id == company_id)
.group_by(LLCHeartbeatRun.agent_id)
.subquery()
)
latest_runs = (
(
await session.execute(
select(LLCHeartbeatRun).join(
subq,
(LLCHeartbeatRun.agent_id == subq.c.agent_id) & (LLCHeartbeatRun.created_at == subq.c.latest_at),
)
)
)
.scalars()
.all()
)
runs = {r.agent_id: r for r in latest_runs}

# Compose flat nodes.
flat: Dict[str, OrgChartNode] = {}
for row in org_rows:
budget = budgets.get(row.agent_id)
run = runs.get(row.agent_id)
flat[row.agent_id] = OrgChartNode(
id=row.agent_id,
name=row.name,
title=row.title or row.org_role,
status=_heartbeat_status_to_org_status(run.status if run else None),
adapter_type=row.org_role,
is_human=False,
# Liveness: latest run is picked by created_at; a just-queued run
# may have no started_at, so fall back to created_at.
last_heartbeat=(
(run.started_at or run.created_at).isoformat() if run and (run.started_at or run.created_at) else None
),
budget_spent=float(budget.budget_spent) if budget else 0.0,
budget_total=float(budget.budget_limit) if budget else 0.0,
assigned_item_count=0,
parent_id=row.reports_to,
children=[],
)

def _chain_resolves_to_root(agent_id: str) -> bool:
"""True if following reports_to from ``agent_id`` ends at a node with no
(or missing/self) parent without revisiting a node — i.e. no cycle."""
seen: set[str] = set()
cur: Optional[OrgChartNode] = flat.get(agent_id)
while cur is not None and cur.parent_id:
if cur.id in seen:
return False # cycle
seen.add(cur.id)
parent = flat.get(cur.parent_id)
if parent is None or parent.id == cur.id:
return True # parent absent/self → effectively rooted
cur = parent
return True

# Assemble the forest from reports_to edges. Attach a node to its parent
# only when its chain is acyclic; cycle members (and nodes whose parent is
# absent/self) become roots with no parent edge, so the output is always a
# true forest — every agent appears exactly once, never infinitely nested.
roots: List[OrgChartNode] = []
for node in flat.values():
parent = flat.get(node.parent_id) if node.parent_id else None
if parent is not None and parent.id != node.id and _chain_resolves_to_root(node.id):
parent.children.append(node)
else:
roots.append(node)

return OrgChartResponse(nodes=roots)


@router.get("/{company_id}/agents/search")
async def search_agents(
company_id: uuid.UUID,
Expand Down
Loading
Loading