From 51159c97924b697e54de60c80f82003eccd77483 Mon Sep 17 00:00:00 2001 From: Luka Skugor Date: Wed, 4 Nov 2026 08:07:19 +0100 Subject: [PATCH 1/4] cockpit v3: steering end to end on the live node API Milestone 2's web seams now read the server: every steering route (preview, apply, Undo, Needs you, Find, last look) is on the live source, and what did not match the S9/S10/S12/S13 server is fixed: - Previews draw the server's own shapes: the moved or added node sits dashed in the column it lands in (the server lists it under ghosts), "Wait: ..." sentences read as labelled lines, a plan's code prefix is left out of warnings, one arrow between before and after, and Apply stays off when the server says the viewer may only look. - Add child sends what it waits on as depends_on: [ref] with depends_why, lands in First unless it waits on a sibling, and says the column the server placed it in. - An answer that applied a proposal (Apply C-16) has its Undo (the proposal's own) in the toast and beside the node's verbs. - A verb's card in its node's thread offers its Undo by token, also after a reload; the server's undone card names the token it undid. - The node chat reads the per-node threads (?nodes=1) for its children's news; on a phone a verb from the full-screen chat closes it and opens the preview in view. - Team's first pick never sends a preview with no changes (400); team rows size by the panel's width, not the window's. - Asks read as words (a person's Markdown), chips end in an ellipsis. - Server: Start now no longer warns about met links it already had, the factory team says "all idle" instead of "0 running", and Name it says it files under a stored node of its own level. The e2e seeds the base env and a held Google Ads integration, and tests/e2e_ui/factory_v3/test_v3_steering.py drives each verb, Needs you, Find, the factory tree, every lens and the phone against a real server. Co-Authored-By: Claude Opus 5.5 --- docs/FACTORY.md | 7 +- omnigent/server/factory/node_verbs.py | 30 +- omnigent/server/factory/nodes.py | 5 +- tests/e2e_ui/factory_v3/checks.py | 74 +++ tests/e2e_ui/factory_v3/seed.py | 53 +- tests/e2e_ui/factory_v3/test_v3_live_api.py | 39 +- tests/e2e_ui/factory_v3/test_v3_steering.py | 519 ++++++++++++++++++ tests/server/factory/test_node_verbs.py | 56 ++ tests/server/factory/test_nodes.py | 8 + web/src/components/factory/cards/RoomCard.tsx | 3 +- .../factory/cards/RoomCardContext.tsx | 4 +- .../factory/room/RoomThreadView.tsx | 2 +- .../components/factory/room/roomQueries.ts | 45 +- .../components/factory/team/teamQueries.ts | 4 +- web/src/components/factory/v3/LinkChip.tsx | 24 +- .../components/factory/v3/chat/ChatCards.tsx | 41 ++ .../factory/v3/chat/NodeChat.test.tsx | 85 +++ .../components/factory/v3/chat/NodeChat.tsx | 80 ++- .../factory/v3/fixture/renderChat.tsx | 15 +- .../factory/v3/fixture/renderNode.tsx | 18 +- .../factory/v3/fixture/sampleNodeSource.ts | 1 + .../factory/v3/fixture/sampleRoom.ts | 11 +- .../factory/v3/fixture/sampleSteer.ts | 50 +- web/src/components/factory/v3/nodeParts.tsx | 6 + web/src/components/factory/v3/nodeSource.ts | 44 +- .../components/factory/v3/nodeSteer.test.tsx | 10 +- web/src/components/factory/v3/nodeSteer.ts | 134 ++++- web/src/components/factory/v3/nodeTypes.ts | 15 +- web/src/components/factory/v3/nodeWire.ts | 78 ++- .../components/factory/v3/sheets/V3Sheets.tsx | 23 +- .../components/factory/v3/steerLive.test.tsx | 315 +++++++++++ .../factory/v3/verbs/PreviewPanel.tsx | 8 +- .../factory/v3/verbs/TeamPanel.live.test.tsx | 5 + .../components/factory/v3/verbs/TeamPanel.tsx | 12 +- .../factory/v3/verbs/VerbPreviews.test.tsx | 2 +- .../factory/v3/verbs/VerbPreviews.tsx | 32 +- web/src/lib/factoryRoomApi.ts | 4 +- web/src/pages/factory/NodeRoute.tsx | 27 +- 38 files changed, 1701 insertions(+), 188 deletions(-) create mode 100644 tests/e2e_ui/factory_v3/checks.py create mode 100644 tests/e2e_ui/factory_v3/test_v3_steering.py create mode 100644 web/src/components/factory/v3/steerLive.test.tsx diff --git a/docs/FACTORY.md b/docs/FACTORY.md index 45f9783be41..ceab02d8930 100644 --- a/docs/FACTORY.md +++ b/docs/FACTORY.md @@ -1189,7 +1189,10 @@ undone, and a `preview_id` that digests the plan and the facts it touches. only that plan (409 `stale`, with the new preview, when what it touches moved), then posts a `preview_applied` card and returns the node page with an Undo token; `POST …/undo/{token}` takes it back once within 10 minutes -(the person who applied it, or an owner). The verbs run today's cores: `add` +(the person who applied it, or an owner). The card carries the token +(`meta.undo_token`, `undo_until`), so cockpit v3's chat offers the same Undo +after a reload; the card an Undo posts (`meta.undone`) names the token it +undid, and the applied card stops offering it. The verbs run today's cores: `add` under a feature files the person's projects proposal extending it and applies it at once ("Plan X now" goes to the feature's thread; a title that matches a held item pulls that item forward; `col: "later"` holds it in a @@ -1305,7 +1308,7 @@ sentences, or (`rollup_delta`) as `{field: {before, after}}` or the S12 server's `{nodes: [{ref, title, items: [{key, label, before, after}]}], text}` (one line per changed item, then the sentence; `null` when nothing in the roll-up changes). The arguments -the web sends: add `{title, col, depends_on?, because?, held_for?}`, start, +the web sends: add `{title, col, depends_on?: [ref], depends_why?: {ref: reason}, held_for?}`, start, pause and resume `{}`, auto `{auto_merge?, autonomy?, base?}` (what is left out stays as it is; `base` once the preview asks for it, `args.base`; the server also takes `repo`, and sends its effects as `{label, text}` lines), move `{to?}`, name diff --git a/omnigent/server/factory/node_verbs.py b/omnigent/server/factory/node_verbs.py index b064ed1bbb3..a26079cc734 100644 --- a/omnigent/server/factory/node_verbs.py +++ b/omnigent/server/factory/node_verbs.py @@ -146,6 +146,8 @@ _SLUG_CHARS: Final = re.compile(r"[^a-z0-9]+") _TOKEN_RE: Final = re.compile(r"^[0-9a-f]{24}$") _PAUSE_HOLD: Final = "paused from the cockpit" +_ENDED_DEP: Final = re.compile(r"^depends_on_(?:done|cancelled): \S+ waits on \w+ (\S+)$") +"""A projects plan's warning that a dependency already finished (``proposals``' wording).""" class VerbIn(BaseModel): @@ -479,6 +481,13 @@ async def run() -> Applied: ) up = tree.level_name(tree.ladder[node.tree][max(0, tree.level_index(node) - 1)]).lower() + # Name it files an implicit node's work under a stored node of its own level. + same = tree.level_name(node.level).lower() + offered = ( + f"only a stored {same} is offered" + if verb == "name" + else f"only a {up} one level up is offered" + ) return Plan( verb, node.ref, @@ -486,9 +495,9 @@ async def run() -> Applied: {}, [f"parents:{_digest([p['ref'] for p in parents])}"], [ - f"{what.capitalize()}: only a {up} one level up is offered." + f"{what.capitalize()}: {offered}." if parents - else f"No stored {up} to put it under yet." + else f"No stored {same if verb == 'name' else up} to put it under yet." ], run, ready=False, @@ -1089,7 +1098,14 @@ async def _plan_start(env: Env, tree: nodes.Tree, node: Any, args: dict[str, Any if patch else None ) - warnings = list(plan.warnings) if plan is not None else [] + # A link it already had to a finished project is history (its chip says "met"): + # starting now neither adds it nor changes it, so it is no warning here. + kept = {d for d in new if d in current} + warnings = [ + w + for w in (plan.warnings if plan is not None else []) + if not ((m := _ENDED_DEP.match(w)) and m.group(1) in kept) + ] if not swaps and unmet and explicit is None: warnings.append("Nothing it waits on can be relaxed to a merged change.") @@ -2850,7 +2866,13 @@ async def undo(env: Env, token: str) -> dict[str, Any]: ref, "preview_applied", f"Undone by {env.by}: {event.summary}", - meta={"verb": str(data.get("verb") or ""), "by": env.by, "undone": True}, + # The token it undid: the applied card in the thread stops offering its Undo. + meta={ + "verb": str(data.get("verb") or ""), + "by": env.by, + "undone": True, + "undo_token": token, + }, message_id=derived_id(env.fid, "undone", token), ) fresh = await env.tree(fresh=True) diff --git a/omnigent/server/factory/nodes.py b/omnigent/server/factory/nodes.py index af6a9c3171e..78bad5e9274 100644 --- a/omnigent/server/factory/nodes.py +++ b/omnigent/server/factory/nodes.py @@ -1342,7 +1342,10 @@ def _team(self) -> None: everyone = self.facts.members if everyone: running = sum(1 for m in everyone if m.get("running")) - team.short = f"{len(everyone)} members · {running} running" + # Nothing renders as "0": idle members are counted, never drawn. + team.short = f"{len(everyone)} members · " + ( + f"{running} running" if running else "all idle" + ) def _areas(self) -> None: """Each area's one line on what it holds; an area with nothing to act on is done.""" diff --git a/tests/e2e_ui/factory_v3/checks.py b/tests/e2e_ui/factory_v3/checks.py new file mode 100644 index 00000000000..5cfc7f4b220 --- /dev/null +++ b/tests/e2e_ui/factory_v3/checks.py @@ -0,0 +1,74 @@ +""" +What every cockpit v3 screen must hold, checked in the browser. Not a test module. + +- :data:`BODY_CHECKS` sweeps a node page and v3's own parts of its chat + column: no line, path or polygon drawn between nodes (icons are glyphs, not + links between nodes, and are left out), no leaf that says only "none", "0" + or "—", and no sideways scroll of the page. +- :func:`node` is a node page by ref; :func:`api` reads the factory's API as + the browser's user (single-user server: no header). +""" + +from __future__ import annotations + +from typing import Any + +import httpx +from playwright.sync_api import Locator, Page + +BODY_CHECKS = """(node) => { + const chat = [...document.querySelectorAll( + '[data-testid="v3-chat"], [data-testid="v3-chat-pinned"], [data-testid="v3-chat-full"]', + )]; + const roots = new Set(chat); + const room = '[data-testid^="decision-card-"], [data-testid^="proposal-card-"], ' + + '[data-testid^="loop-card-"]'; + const own = (el) => { + const part = el.closest('[data-testid^="v3-"]'); + return part !== null && !roots.has(part) && el.closest(room) === null; + }; + const icon = (el) => el.closest('svg.lucide') !== null; + const body = (el) => !chat.some((c) => c.contains(el)) || own(el); + const shown = (sel) => [...document.querySelectorAll(sel)] + .filter((el) => node.contains(el) || chat.some((c) => c.contains(el))) + .filter(body); + const lines = shown('svg line, svg path, svg polyline, svg polygon') + .filter((el) => !icon(el)) + .map((el) => el.closest('[data-testid]')?.dataset.testid ?? el.tagName); + const empty = new Set(['none', 'None', '—', '-', '0']); + const cells = shown('*') + .filter((el) => el.children.length === 0) + .map((el) => (el.textContent ?? '').trim()) + .filter((text) => empty.has(text) || /^0(\\s|$)/.test(text)); + return { + lines, + cells, + chat: shown('*').filter((el) => chat.some((c) => c.contains(el))).length, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; +}""" +"""Run with ``node.evaluate(BODY_CHECKS)`` on a ``v3-node-page``.""" + + +def node(page: Page, ref: str) -> Locator: + """:returns: The node page of *ref*.""" + return page.locator(f'[data-testid="v3-node-page"][data-ref="{ref}"]') + + +def api(base_url: str, fid: str, path: str, **params: Any) -> Any: + """``GET /v1/factory/factories/{fid}{path}`` as the browser's user; the JSON body.""" + resp = httpx.get( + f"{base_url}/v1/factory/factories/{fid}{path}", params=params or None, timeout=30.0 + ) + assert resp.status_code == 200, (path, resp.status_code, resp.text) + return resp.json() + + +def assert_clean(found: dict[str, Any], *where: object) -> None: + """A :data:`BODY_CHECKS` result with no line, no empty cell and no sideways scroll.""" + assert found["lines"] == [], (*where, "lines", found["lines"]) + assert found["cells"] == [], (*where, "empty", found["cells"]) + assert found["overflow"] <= 0, (*where, "overflow", found["overflow"]) + + +__all__ = ["BODY_CHECKS", "api", "assert_clean", "node"] diff --git a/tests/e2e_ui/factory_v3/seed.py b/tests/e2e_ui/factory_v3/seed.py index 9b7e2af9e2e..2d5dea9ee97 100644 --- a/tests/e2e_ui/factory_v3/seed.py +++ b/tests/e2e_ui/factory_v3/seed.py @@ -15,7 +15,10 @@ a person to merge them into ``main``; - C-14 and C-15 ask things that block nothing; C-19 (D-49) proposes a follow-up under I-1; C-17 and C-18 are rule proposals for the factory tree; -- the server closed three alarms today (D-46, D-47, D-48). +- the server closed three alarms today (D-46, D-47, D-48); +- the base env (the integration project ``base-env``) runs the base's head, + which holds every merge, and answers its smoke checks; +- Agent assistant's later waves hold "Google Ads integration" and a sales skill. Rows are written with the server's own stores (the same SQL tables the server reads), and C-16 is filed through ``POST …/proposals`` so its decision (D-45) @@ -42,6 +45,14 @@ from omnigent.db.db_models import SqlFactoryDecision, SqlFactoryProject from omnigent.entities.factory import EventDraft from omnigent.server.auth import LEVEL_OWNER, RESERVED_USER_LOCAL +from omnigent.server.factory.base import ( + BaseHead, + DeployedHead, + deployed_label, + deployed_value, + head_label, + head_value, +) from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore from omnigent.stores.factory_store.sqlalchemy_store import SqlAlchemyFactoryStore @@ -59,6 +70,8 @@ "discovery and sales too." ) H: Final = 3600 +DEPLOYED_SHA: Final = "5a3c9e1" + "0" * 33 +"""The base head the base env runs (it holds every merge of the story).""" @dataclass(frozen=True) @@ -338,7 +351,25 @@ def seed_factory_1340( } for slug in SHORTLIST }, - "base-env": {"stage": "planning", "charter": {"kind": "integration"}}, + "base-env": { + "stage": "planning", + "charter": {"kind": "integration"}, + "env": { + "reported": { + "state": "ready", + "at": int(clock - 12 * 60), + "urls": {"app": "https://app.example.com", "api": "https://api.example.com"}, + "deployed": {"sha": DEPLOYED_SHA[:7]}, + "note": "Sign in with a test account (sample).", + }, + "probe": { + "health": "ok", + "app": {"code": 200}, + "api": {"code": 200}, + "at": int(clock - 60), + }, + }, + }, } made: dict[str, float] = {} for slug, fields in work.items(): @@ -384,7 +415,11 @@ def seed_factory_1340( {"wave": 1, "proposal": "C-11", "added": list(WAVE_1), "at": int(clock - 7 * H)} ], "waves": [ - {"n": 2, "goal": "ads network and growth", "projects": ["ads-integration"]}, + { + "n": 2, + "goal": "ads network and growth", + "projects": ["google-ads-integration"], + }, {"n": 3, "goal": "sales and promotion", "projects": ["sales-skill"]}, ], "asked_at": int(clock - 7 * H - 45 * 60), @@ -620,6 +655,18 @@ def seed_factory_1340( ) fs.update_proposal(proposal_row.id, decision_id=row.id) + # ── The base env: the base's head after the last merge, deployed and answering ── + moved = int(clock - 14 * 60) + conv.set_labels( + fid, + { + head_label(REPO, BASE): head_value(BaseHead(DEPLOYED_SHA, moved), REPO, BASE), + deployed_label(REPO, BASE): deployed_value( + DeployedHead(DEPLOYED_SHA, int(clock - 12 * 60), moved), REPO, BASE + ), + }, + ) + # ── An hour of work on assistant-core (its burn) ── fs.insert_span( fid, diff --git a/tests/e2e_ui/factory_v3/test_v3_live_api.py b/tests/e2e_ui/factory_v3/test_v3_live_api.py index 063179bfc24..89e95437476 100644 --- a/tests/e2e_ui/factory_v3/test_v3_live_api.py +++ b/tests/e2e_ui/factory_v3/test_v3_live_api.py @@ -27,6 +27,8 @@ import pytest from playwright.sync_api import Page, expect +from tests.e2e_ui.factory_v3.checks import BODY_CHECKS as _BODY_CHECKS +from tests.e2e_ui.factory_v3.checks import node as _node from tests.e2e_ui.factory_v3.conftest import LOAD_MS, Cockpit, Theme, V3Server from tests.e2e_ui.factory_v3.seed import Seeded @@ -35,43 +37,6 @@ LEVEL_PAGES = (("product", "project"), ("product", "task"), ("I-1", "task")) """Level views the strip links to (``/n//``).""" -# What the node page shows: the page, and v3's own parts of its chat column (title, -# quick verbs, any why/ask/alarm/offer card). The Room's answer cards and the chat's -# icons stay as they are. -_BODY_CHECKS = """(node) => { - const chat = [...document.querySelectorAll( - '[data-testid="v3-chat"], [data-testid="v3-chat-pinned"]', - )]; - const roots = new Set(chat); - const room = '[data-testid^="decision-card-"], [data-testid^="proposal-card-"], ' - + '[data-testid^="loop-card-"], svg.lucide'; - const own = (el) => { - const part = el.closest('[data-testid^="v3-"]'); - return part !== null && !roots.has(part) && el.closest(room) === null; - }; - const body = (el) => !chat.some((c) => c.contains(el)) || own(el); - const shown = (sel) => [...document.querySelectorAll(sel)] - .filter((el) => node.contains(el) || chat.some((c) => c.contains(el))) - .filter(body); - const lines = shown('svg line, svg path, svg polyline, svg polygon') - .map((el) => el.closest('[data-testid]')?.dataset.testid ?? el.tagName); - const empty = new Set(['none', 'None', '—', '-', '0']); - const cells = shown('*') - .filter((el) => el.children.length === 0) - .map((el) => (el.textContent ?? '').trim()) - .filter((text) => empty.has(text) || /^0(\\s|$)/.test(text)); - return { - lines, - cells, - chat: shown('*').filter((el) => chat.some((c) => c.contains(el))).length, - overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, - }; -}""" - - -def _node(page: Page, ref: str): - return page.locator(f'[data-testid="v3-node-page"][data-ref="{ref}"]') - def _api_page(v3_server: V3Server, factory: Seeded, ref: str) -> dict: resp = httpx.get( diff --git a/tests/e2e_ui/factory_v3/test_v3_steering.py b/tests/e2e_ui/factory_v3/test_v3_steering.py new file mode 100644 index 00000000000..e1df14e1df5 --- /dev/null +++ b/tests/e2e_ui/factory_v3/test_v3_steering.py @@ -0,0 +1,519 @@ +""" +Cockpit v3 steering on a real server: every verb previews, applies and undoes +through the node API, in the browser. + +The server holds a factory shaped like a real one at 13:40 (``seed.py``). Each +test seeds its own, so what one applies never leaks into another: + +- Apply on a decision answers it, and its Undo (the proposal's own) takes it back; +- Start now on the waiting panel: the preview moves it to First in its + feature's columns, Apply relaxes its wait to the merged change, the status + line changes, Undo restores it; +- Add child "Google Ads integration" under the feature pulls the held item + forward (no second copy) and asks Atlas to plan it in the feature's thread; +- Autonomy & merge on the feature turns auto-merge on for its subtree: the + preview says what it covers and what it leaves to people, only an owner + applies it, and every node under it shows it inherited; +- Team: swapping one member's model shows the team's cost per hour before and + after, applies as a definition version, and Undo re-pins the one before; +- Needs you lists the asks ranked by what each holds; Find opens nodes by + T-16, #9876 and C-16; the factory tree shows the base env; +- every lens renders with no line between nodes and no empty cell; +- at 390 the chat opens full screen and every verb is reachable. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable + +import httpx +from playwright.sync_api import Locator, Page, expect + +from tests.e2e_ui.factory_v3.checks import BODY_CHECKS, api, assert_clean, node +from tests.e2e_ui.factory_v3.conftest import LOAD_MS, Cockpit, Theme, V3Server +from tests.e2e_ui.factory_v3.seed import BASE, DEPLOYED_SHA, REPO, Seeded + +PANEL = "assistant-panel-ux" +FEATURE_TITLE = "Agent assistant (sample)" +GOOGLE_ADS = "Google Ads integration" + + +def _verb(page: Page, ref: str, label: str) -> Locator: + """The verb button *label* in the toolbar of node *ref*.""" + bar = node(page, ref).get_by_test_id("v3-verbs") + return bar.get_by_role("button", name=re.compile(rf"^{re.escape(label)}\b")) + + +def _preview(page: Page) -> Locator: + """The open verb preview, once the server's dry run is in.""" + preview = page.get_by_test_id("v3-preview") + expect(preview).to_be_visible(timeout=LOAD_MS) + expect(preview.get_by_test_id("v3-preview-lines")).to_be_visible(timeout=LOAD_MS) + expect(preview).not_to_have_attribute("aria-busy", "true", timeout=LOAD_MS) + return preview + + +def _line(preview: Locator, label: str) -> Locator: + """The preview line whose label is *label* ("Who approves", "Cost" …).""" + return preview.get_by_test_id("v3-preview-line").filter( + has=preview.page.locator("dt", has_text=re.compile(rf"^{re.escape(label)}$")) + ) + + +def _apply(page: Page, preview: Locator, label: str | re.Pattern[str]) -> None: + """Press the preview's Apply (*label*) and wait for ``POST …/apply`` to succeed.""" + button = preview.get_by_test_id("v3-preview-apply") + expect(button).to_have_text(label) + expect(button).to_be_enabled(timeout=LOAD_MS) + with page.expect_response( + lambda r: r.request.method == "POST" and r.url.endswith("/apply") + ) as applied: + button.click() + assert applied.value.status == 200, applied.value.text() + expect(preview).to_have_count(0, timeout=LOAD_MS) + + +def _undo_latest(page: Page, ref: str, label: str) -> Locator: + """The "