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
21 changes: 17 additions & 4 deletions docs/FACTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ if they stay silent the board is still right.
| `GET …/{fid}/proposals?kind=&status=`, `GET …/proposals/{ref}`, `POST …/proposals/{ref}/apply` (`{changes?}`), `…/reject` (`{note?}`), `…/undo` | human (EDIT; MANAGE for an owner-class proposal) | Proposals `C-<n>`; apply and reject answer the mirrored decision, so one of two clicks wins. |
| `POST /v1/factory/factories/{fid}/control` | human (EDIT; MANAGE for STOP, RESTART, RELAUNCH) | PAUSE, RESUME, STOP, RESTART, RELAUNCH, cadence, PREVIEW / PREVIEW_DOWN of a PR, ENV STATUS: a server-stamped `[Factory control …]` to the members. |
| `GET /v1/factory/decisions`, `POST …/decisions/{id}/answer`, `…/withdraw` | human | Open decisions (also in `/inbox`); the answer is delivered to the asker as `[Factory decision · D-<n> answered …]`. |
| `POST …/decisions/{id}/undo` | human (EDIT; MANAGE for an owner-class proposal) | Takes back an Apply on a projects proposal's decision within the proposal's Undo window: the proposal is undone and waits for a person again under a fresh decision (`{proposal, decision}`); the answered decision stays answered. |
| `POST /v1/factory/prs/{id}/feedback`, `…/refresh` | human | Approve or request changes (`[Factory review …]` to the PL); re-poll GitHub now. |
| `GET /v1/factory/prs/{id}/preview` | human | Stable live-preview link: 302 to the current preview app URL, or back to the cockpit when there is none. |
| `GET /v1/factory/demos/{demo_id}/files/{file_id}` | human (READ on the PL) | Demo files with Range/206 (a ranged read of the store) and ETag; only images and video inline, never HTML or SVG. |
Expand Down Expand Up @@ -1189,7 +1190,11 @@ 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`) and who applied it (`meta.by_user`), so
cockpit v3's chat offers the same Undo after a reload, to that person or an
owner only; 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
Expand Down Expand Up @@ -1238,8 +1243,16 @@ parents one level up. Plain words in the node's chat ("start the panel now",
Team is the team editor in a panel (every member's vendor and model, nothing
preselected, the $/h before and after) on the definition's own preview, a
definition version and Undo by re-pinning the version before; the other
verbs wait for `POST …/n/{ref}/preview|apply` and `POST …/undo/{token}`
and until then say they cannot be previewed. Sheets open from the address:
verbs preview and apply through `POST …/n/{ref}/preview|apply` and undo
through `POST …/undo/{token}`. An answer that applied a projects proposal
(Apply on D-45 applies C-16) keeps an Undo while the proposal's
`undo_until` lasts (a rules, tuning or code change, a cancel, split or
merge has none, so none is offered): `POST /v1/factory/decisions/{id}/undo`
undoes the proposal and puts it back to pending under a fresh decision, so
the work held for it waits again (a hold the sweep let lapse comes back,
and its PL is told `[Factory notice · HOLD BACK …]`) and the ask is in Needs
you again. An answer the server stored but whose proposal failed to apply
says so ("D-50 answered; applying C-17 failed: …"), with no Undo. Sheets open from the address:
`?peek=needs` (Needs you, ranked by what each ask holds, the asks that block
nothing grouped with "Apply all", the closed alarms, the other tree's count;
from `GET …/needs-you`), `?peek=env` (the base env), `?peek=try&at=<ref>`
Expand Down Expand Up @@ -1305,7 +1318,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
Expand Down
9 changes: 6 additions & 3 deletions omnigent/server/factory/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
responder, last ``seq``, the caller's unread count and ``node_ref`` (the node
it is about: ``product``, a project's slug, ``PR-<n>``, ``T-16`` …; ``null``
for ``#discovery``). ``nodes=1`` adds the node threads (``need:``,
``feature:``, ``study:``, ``task:``) that have messages. READ.
``feature:``, ``study:``, ``task:``) that have messages, the newest 50. READ.
- ``GET /v1/factory/factories/{fid}/chat?thread=&before_seq=&after_seq=&limit=``:
a page of messages, oldest first. READ.

Expand Down Expand Up @@ -62,6 +62,8 @@
PAGE_MAX: Final = 200
UNREAD_CAP: Final = 99
MENTION_PREVIEW_CHARS: Final = 200
NODE_THREADS_MAX: Final = 50
"""Node threads ``?nodes=1`` lists at most (the newest): each costs a lookup and an unread read."""
_T = TypeVar("_T")

# Factory events become cards in the Room.
Expand Down Expand Up @@ -186,7 +188,8 @@ def thread_views(

:param hidden: Project slugs left out, with their PR threads (and their tasks' threads).
:param nodes: Also the node threads (``need:``, ``feature:``, ``study:``, ``task:``)
that have messages, newest first, after the PRs.
that have messages, newest first, after the PRs (the newest
:data:`NODE_THREADS_MAX`: each costs a lookup and an unread read).
:returns: ``ThreadView`` JSON, fixed threads first, then projects, then PRs (newest first).
"""
store = deps.factory_store
Expand All @@ -202,7 +205,7 @@ def thread_views(
if nodes:
node_keys = [k for k in stats if thread_kind(k) in chat_routing.NODE_KINDS]
node_keys.sort(key=lambda k: -stats[k].last_seq)
keys += [k for k in node_keys if k not in keys]
keys += [k for k in node_keys[:NODE_THREADS_MAX] if k not in keys]
threads: list[chat.Thread] = []
for key in keys:
try:
Expand Down
18 changes: 17 additions & 1 deletion omnigent/server/factory/api/cockpit_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Routes (user JWT; access per route, see each module):

- ``POST /v1/factory/decisions/{id}/answer`` / ``…/withdraw`` (:mod:`..decisions`)
- ``POST /v1/factory/decisions/{id}/undo``: take back an Apply on a proposal's
decision (:func:`..proposals.undo_answer`)
- ``POST /v1/factory/factories/{fid}/control`` (:mod:`..control`)
- ``POST /v1/factory/prs/{id}/refresh`` / ``…/feedback`` (:mod:`..control`)
- ``POST /v1/factory/factories``, ``POST …/{fid}/projects``,
Expand All @@ -24,7 +26,7 @@
from pydantic import BaseModel, ConfigDict, Field

from omnigent.entities.factory import FactoryEvent, FactoryProject
from omnigent.server.factory import control, decisions, feedback, start
from omnigent.server.factory import control, decisions, feedback, proposals, start
from omnigent.server.factory.context import FactoryDeps, factory_deps
from omnigent.server.factory.control import CockpitError
from omnigent.server.factory.envelope import display_title
Expand Down Expand Up @@ -129,6 +131,20 @@ async def run() -> dict[str, Any]:
return await _guard(run)


@router.post("/decisions/{decision_id}/undo", response_model=None, dependencies=HUMAN_ORIGIN)
async def undo_answer(request: Request, decision_id: str) -> dict[str, Any] | JSONResponse:
"""
Take back an Apply on a proposal's decision within its Undo window: the
proposal is undone and waits for a person again under a fresh decision.
"""
_, user_id = _caller(request)

async def run() -> dict[str, Any]:
return await proposals.undo_answer(request.app.state, decision_id, user_id=user_id)

return await _guard(run)


@router.post("/decisions/{decision_id}/withdraw", response_model=None, dependencies=HUMAN_ORIGIN)
async def withdraw_decision(
request: Request, decision_id: str, body: WithdrawBody | None = None
Expand Down
11 changes: 10 additions & 1 deletion omnigent/server/factory/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,16 @@ def _meta_view(meta: object) -> dict[str, Any] | None:
"kind": "preview_applied",
**{
k: meta[k]
for k in ("verb", "label", "by", "undo_token", "undo_until", "filed", "undone")
for k in (
"verb",
"label",
"by",
"by_user",
"undo_token",
"undo_until",
"filed",
"undone",
)
if isinstance(meta.get(k), str | int | bool) and not isinstance(meta.get(k), float)
},
}
Expand Down
3 changes: 2 additions & 1 deletion omnigent/server/factory/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,8 @@ async def _run_proposal_answer(
decision=view,
)
if isinstance(exc, CockpitError):
failure.extra = {**exc.extra, "decision": view}
# Why the proposal did not apply, in its own words (a cockpit refusal's text).
failure.extra = {**exc.extra, "decision": view, "reason": exc.message}
return answered, None, None, failure
if not sent:
answered = (
Expand Down
33 changes: 29 additions & 4 deletions omnigent/server/factory/node_verbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -479,16 +481,23 @@ 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,
f"{'Name' if verb == 'name' else 'Move'} {node.title}",
{},
[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,
Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -2777,6 +2793,9 @@ async def _record(env: Env, plan: Plan, applied: Applied) -> dict[str, Any] | No

async def _card(env: Env, plan: Plan, applied: Applied, undo: Mapping[str, Any] | None) -> None:
meta: dict[str, Any] = {"verb": plan.verb, "label": plan.title, "by": env.by}
if env.user_id:
# Who may undo it from the card: this person, or an owner.
meta["by_user"] = env.user_id
if applied.filed:
meta["filed"] = applied.filed
if undo:
Expand Down Expand Up @@ -2850,7 +2869,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)
Expand Down
5 changes: 4 additions & 1 deletion omnigent/server/factory/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading
Loading