Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 35 additions & 3 deletions docs/FACTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ SERVER: FactorySupervisor — keepalive, heartbeats, alerts, reply notices
| `OMNIGENT_FACTORY_POST_RATE_PER_HOUR` | `60,20` | Posts per hour per sender, and per (sender, target) pair. A single number sets the sender limit and a pair limit of a third of it. |
| `OMNIGENT_FACTORY_MERGE_REPOS` | empty | `owner/repo` list (comma or space separated) the merge gate may merge in. Empty means the gate merges nowhere. Read by the gate inside the sandbox. |
| `OMNIGENT_FACTORY_ARTIFACT_LOCATION` | unset | Where demo files (video, screenshots, `api.json`, HAR, traces) are written: `s3://<bucket>[/<prefix>]` (credentials from the standard AWS chain, IRSA included; `AWS_REGION` must be set for AWS S3; `AWS_ENDPOINT_URL_S3` for MinIO or R2) or a directory. Unset keeps them session files of the PL in the server's artifact store, shown in its Files panel. Each demo file records the location it was written to and is always read from there, so demos stored before a change keep playing. Files are still served only through `/v1/factory/demos/…` behind omnigent auth (a Range is a ranged read of the store, at most 4 MiB per response; no presigned links). A store that fails (unreachable, throttled, denied) answers 503 `artifact_store_unavailable` with `Retry-After`. A malformed location fails server start. |
| `OMNIGENT_FACTORY_COCKPIT_V2` | unset | `1` lets each person switch to the new cockpit (Teams and the agent panel; `?cockpit=v2`, remembered per browser). Unset, every page and API behaves as before. |
| `OMNIGENT_FACTORY_COCKPIT_V2` | unset | `1` turns on the team editor's staged changes and the agent panel's routes. The web cockpit no longer reads it: everyone sees the one cockpit (Home, Demos, Chats, Decisions and the node pages); `?cockpit=` links are dropped. |
| `OMNIGENT_FACTORY_DISCOVERY` | unset | `1` runs discovery on this server: the discovery ledger's routes, Room hunches as team signals, the ledger's upkeep (retention, daily score snapshots), the definition preview's re-rank, readouts and the prediction ledger, and trials of discovery's method on discovery guards. A factory also needs its own `discovery.enabled` (owners, off by default); until both are on every discovery route answers `404 discovery_off`. |
| `OMNIGENT_FACTORY_WEB_SEARCH` | unset | The keyless search backend of `web` sources: `keenable` (its public, keyless endpoint; no key, no account). Unset or `none`, `web` sources fetch only (their `search` tool is not offered). Turn one on only where the server can reach it. |
| `OMNIGENT_FACTORY_STATE_SEGMENTS` | follows `OMNIGENT_FACTORY_COCKPIT_V2` | `1` records each agent's working and waiting stretches (the agent panel's strip); any other value turns recording off. Segments older than 30 days are pruned. One server process is assumed to see all of a session's status edges. |
Expand Down Expand Up @@ -1063,15 +1063,27 @@ under their task's PR; a task with runs and no PR yet has no run page
viewer (`filter_snapshot`: members, team chips, decisions, cost and burn,
the base env), memoized for 5 s per factory and set of sessions the viewer
may read, and dropped on any recorded write; open asks come from one
batched read of the messages live nodes quote. Cockpit v3 (`?cockpit=v3`)
always reads these pages; its sample factory lives only in Storybook and
batched read of the messages live nodes quote. The cockpit always reads
these pages; its sample factory lives only in Storybook and
the web tests. `GET …/n/{ref}/level/{level}` lists one level under a node
grouped under each item's parent (Home lists the features under the needs
they came from), all-done groups folded, 60 a group with a cursor each;
`state` and `q` keep only the nodes in one state or whose title or code
holds the words (`state=later` or `ghost` lists the held or proposed nodes
as the items); a group with nothing to show is left out.

**The cockpit** is one shell for everyone: a sidebar with Home, Demos,
Chats, Decisions and Search, then an outline of the work (features opening
to their projects, the page's node marked); a phone has the places in a
bottom tab bar. Home is what needs you (three asks), the features one line
each with a ▶ for their latest demo, and what shipped lately. Demos reads
`GET …/n/product/demos?limit=100`, whose items also say where each sits
(`feature`, `project`, `merged_at`). Chats reads `GET …/chat/threads?nodes=1`,
whose threads also say whether the reader wrote there (`mine`) and the newest
thing a person or agent said (`last`). Older addresses (`/projects/<slug>`,
`/prs/<id>`, `/initiatives/<ref>`, `/room`, `?review=`, `?peek=needs`) land
on the place that shows the same thing now.

**Lenses** (`node_lenses.py`) are the other ways to read one node.
`GET …/n/{ref}/lineage` reads top to bottom: each ancestor with its one
line (a need with its evidence, a feature with the proposal that approved
Expand All @@ -1094,6 +1106,26 @@ count, and lanes never join. `GET …/n/{ref}/money` lists the children's cost a
their budgets (the roll-ups' numbers), most first, the total, and `rest`:
what the node spent that no child holds (its leads and agents).

**Proof** (`node_proof.py`; calm rule 10, "proof is never hidden"). Every
recorded demo the viewer may play belongs to one node: a PR's demo to its
change, an acceptance run's recording to its feature, a demo of a PR the tree
has no change for to its project. A node page carries `proof: {latest?,
count}`: `count` is its subtree's demos, and `latest` (the one row the page
shows by default, "Latest demo · PASS · 0:14 ▶") is, for a change and the task
that owns it, the demo at the PR's current head (else its newest, `stale`);
for a project its newest across its PRs, current heads first; for a feature
its newest acceptance-run recording, else its newest project demo. Each is
`{id, verdict, title (a few words), duration, poster_url, video_url (the demo
file routes), pr_number, at, partial_note}`; `partial_note` says in one line
what did not pass ("Step 6: live search fails on the base env"). A feature's
row (and a project shown in place of its folded implicit feature) carries
`demo: {id, verdict, duration}`, Home's "▶". `GET …/n/{ref}/demos?cursor=
&limit=` lists the subtree's demos newest first (what "n more demos" opens).
A viewer reads what they may of the PRs: demos of a project whose lead they
cannot read are left out, and so are demos whose files belong to a session
they cannot read. In the node chat a demo card is conversation, never
Activity.

**Needs you and Find** (`node_sheets.py`). `GET …/factories/{fid}/needs-you
?tree=product|factory` lists every ask a person owes in one tree, ranked by
the work it holds (the blocker paths' weight: the estimates of the tasks it
Expand Down
34 changes: 34 additions & 0 deletions omnigent/server/factory/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ def thread_views(
if thread.project not in hidden:
threads.append(thread)
members = chat.members_of(deps, root, threads)
said = store.chat_thread_said(root.id, user_id)
names = _author_names(deps, [m for m, _ in said.values()])
views: list[dict[str, Any]] = []
for thread in threads:
key = thread.key
Expand All @@ -252,11 +254,43 @@ def thread_views(
"last_at": stat.last_at if stat else None,
"messages": stat.messages if stat else 0,
**_unread(deps, root.id, key, reads.get(key, 0), stat, user_id),
**_said(said.get(key), names, user_id),
}
)
return views


LAST_LINE_CHARS = 160
"""How much of a thread's newest line the Chats list shows."""


def _author_names(deps: FactoryDeps, rows: list[Any]) -> dict[str, str]:
"""Message author -> the name a list shows (an agent's session title, a person's id)."""
out: dict[str, str] = {}
for row, view in zip(rows, chat.message_views(deps, rows), strict=True):
out[row.author] = str(view.get("author_title") or row.author)
return out


def _said(found: Any, names: Mapping[str, str], user_id: str | None) -> dict[str, Any]:
""":returns: ``{mine, last}``: whether the reader posted here, and the newest line said."""
if found is None:
return {"mine": False, "last": None}
row, mine = found
line = " ".join(row.body.split())
return {
"mine": mine,
"last": {
"who": "You"
if user_id and row.author == user_id
else names.get(row.author, row.author),
"kind": row.author_kind,
"text": line[:LAST_LINE_CHARS],
"at": row.created_at,
},
}


def _unread(
deps: FactoryDeps, factory_id: str, thread: str, last_read: int, stat: Any, user_id: str | None
) -> dict[str, Any]:
Expand Down
53 changes: 53 additions & 0 deletions omnigent/server/factory/api/node_demos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
A node's recorded demos (calm rule 10, "Proof is never hidden").

``GET /v1/factory/factories/{fid}/n/{ref}/demos`` lists the demos of the
node's subtree, newest first, ``limit`` a page (``cursor``: a page's
``next``), each with its verdict, short title, length, poster and video (the
demo file routes) and its PR (:mod:`omnigent.server.factory.node_proof`). The
node page carries the one it shows by default (``proof.latest``) and the
count; this is what "n more demos" opens. A viewer reads what they may read of
the PRs: demos of a project whose lead they cannot read are left out, and so
are demos whose files belong to a session they cannot read. 404
``unknown_node`` for a ref the factory has no node for; 400 ``bad_cursor``
for a cursor no page handed out.
"""

from __future__ import annotations

from typing import Any

from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse

from omnigent.server.factory import node_proof, node_views, nodes
from omnigent.server.factory.api.agents import fleet_context

router = APIRouter(prefix="/v1/factory", tags=["factory"])


def _error(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})


@router.get("/factories/{factory_id}/n/{ref}/demos", response_model=None)
async def get_node_demos(
request: Request,
factory_id: str,
ref: str,
cursor: str | None = None,
limit: int = Query(node_proof.PAGE_SIZE, ge=1, le=node_proof.PAGE_MAX),
) -> dict[str, Any] | JSONResponse:
"""The node's subtree's recorded demos, newest first."""
ctx = await fleet_context(request, factory_id)
tree = await nodes.tree_for(
ctx.deps, ctx.root, ctx.raw, ctx.readable, app_state=request.app.state
)
found = tree.lookup(ref)
if found is None:
return _error(404, "unknown_node", f"no node {ref!r} in this factory")
try:
view = node_proof.demos_page(tree, tree.fold(found), cursor=cursor, limit=limit)
except ValueError as exc:
return _error(400, "bad_cursor", str(exc))
return node_views.validate(node_views.NodeDemosView, view)
18 changes: 11 additions & 7 deletions omnigent/server/factory/chat_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

Cockpit v3's node chat shows what people and agents say, plus the live cards
that need the person reading it (an open ask they answer, a merge they make,
an offer, the "why" they asked for, a verb they can still undo). Everything
else the Room mirrors from events is activity: base moves, members spawned,
incidents, loops, controls, PR and demo news, closed alarms, and every
decision or proposal that is answered, applied, closed or someone else's.
Activity never shows inline; it folds into one "Activity · n since you
looked" line that opens it as a list. Resolved incidents and closed or
applied proposals are never conversation.
an offer, the "why" they asked for, a verb they can still undo), and the
recorded demos (proof is never hidden). Everything else the Room mirrors from
events is activity: base moves, members spawned, incidents, loops, controls,
PR news, closed alarms, and every decision or proposal that is answered,
applied, closed or someone else's. Activity never shows inline; it folds into
one "Activity · n since you looked" line that opens it as a list. Resolved
incidents and closed or applied proposals are never conversation.

:func:`classify` sorts one row by the live state of what its card shows (the
open decisions, proposals and PRs read once per request: :class:`Live`). :func:`feed_page` is
Expand Down Expand Up @@ -53,6 +53,8 @@
"""Counts since the read mark stop here (the cockpit shows "99+")."""
_ASKING_CARDS: Final = frozenset({"why", "offer"})
"""Cards that answer or ask the reader directly: conversation while they stand."""
_PROOF_CARDS: Final = frozenset({"demo"})
"""Cards that show what was built (a recorded demo): always conversation."""
_CLOSED_ISSUES: Final = frozenset({"resolved", "wontfix"})


Expand Down Expand Up @@ -208,6 +210,8 @@ def classify(row: FactoryChatMessage, live: Live, *, closed: bool = True) -> Sor
return Sorted(ACTIVITY, kind, closed=issue is None or issue.status in _CLOSED_ISSUES)
if kind in _ASKING_CARDS:
return Sorted(CONVERSATION, kind, needs_you=kind == "offer")
if kind in _PROOF_CARDS:
return Sorted(CONVERSATION, kind)
if kind == "preview_applied":
meta = _meta(card)
until = meta.get("undo_until")
Expand Down
Loading
Loading