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
19 changes: 19 additions & 0 deletions docs/FACTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ transcript folds.
| `OMNIGENT_FACTORY_PREVIEW_DOMAIN` | unset | The deployment's env domain: the only domain env and preview URLs may use (SSRF guard) for a factory whose product profile sets no `product.delivery.preview_domain`. `OMNIGENT_ENV_DOMAIN` (the sandboxes' env domain) is read when it is unset. With neither, no env URL is accepted. |
| `OMNIGENT_FACTORY_PREVIEW_DOMAINS` | the env domain | Tier 0: comma-separated domains a product profile may name as its `product.delivery.preview_domain` (the env domain is always one). A definition change to any other domain is refused (422 `tier0`); a stored one the deployment no longer lists is ignored with a warning, and the env domain applies. |
| `OMNIGENT_FACTORY_DELIVERY_SEED` | unset | JSON keyed by repo, `{"<owner/repo>": {<product.delivery fields>}}` (`merge_policy`, `auto_merge`, `protected_repos`, `demo_paths`, `preview_domain`, `release`, `flags`): the profile a factory on that repo starts from. A new factory whose default repo is listed gets it in v1; a factory from before the product profile whose default or project repo is listed gets it once as a seed version at boot. A factory on another repo starts with an empty profile. Owners edit it afterwards; a factory whose profile was already seeded or set takes new `auto_merge` targets only from an owner in Steer. Example: `{"sample-org/sample-app": {"auto_merge": [{"repo": "sample-org/sample-app", "base": "staging"}]}}`. |
| `OMNIGENT_FACTORY_AUTO_MERGE_SEED` | unset | JSON keyed by repo, `{"<owner/repo>": [{"base": "<branch>", "repo"?: "<owner/repo>"}]}`: the auto-merge targets (`product.delivery.auto_merge`) a factory on that repo starts with, or gets once at boot as a seed version while no version of its definition ever set targets (one that set or cleared them is never touched). Every target names its base, so `main` is a target only when named; an invalid value seeds nothing (logged). See "Auto-merge targets". Example: `{"sample-org/sample-app": [{"base": "staging"}]}`. |
| `OMNIGENT_FACTORY_ENVIRONMENT_SEED` | unset | JSON keyed by repo, `{"<owner/repo>": {<environment fields>}}` (`summary`, `services`, `tooling`, `registry`, `base_image`, `pull_secret`, `notes`, `conventions`, `protected_branches`, `demo`): the environment profile a factory on that repo starts from, seeded like `OMNIGENT_FACTORY_DELIVERY_SEED` (in v1, or once at boot for a factory from before it). The seed's `repo` is its key unless it names one, so it reaches only that repo's projects. An invalid value seeds nothing (logged). |
| `OMNIGENT_FACTORY_ALWAYS_DENY_REPOS` | unset | Tier 0: comma-separated `owner/repo` values no factory agent ever writes, whatever a spec, a project or a profile says. Read once at boot. Unset, the server logs an error at start and the cockpit health strip shows "Config: no always-deny repos configured". |
| `OMNIGENT_DOMAIN` | unset | Public host of this server, e.g. `omnigent.example.com`. Demo PR comments and `demo_url` use it for absolute links; unset, they use the address the runner called. |
Expand Down Expand Up @@ -2297,6 +2298,24 @@ ci, claim, plan, intake, improve); `factory_loops` keeps one row per
targets under their own top-level key (`auto_merge_targets`), so a server
from before them drops only the targets on a rollback and keeps the rest of
the profile (`protected_repos` included).

A deployment can set the targets so no one has to click:
`OMNIGENT_FACTORY_AUTO_MERGE_SEED`, e.g.
`{"sample-org/sample-app": [{"base": "staging"}]}` (each target's `repo` is
the key unless it names another). A new factory whose default repo is a key
starts with those targets in v1. A factory that already exists gets them
once, at boot, as a recorded definition version (`created_via: "seed"`, by
`server`: the deployment, not an agent), and only when its default repo or
a project repo a person gave it is a key and no version of its definition
ever set `product.delivery.auto_merge` (a factory whose owners set targets,
or set and later cleared them, is never touched). The root's
`omnigent.factory.auto_merge_seed` label (`v<n>` or `set`) keeps it from
running twice. Every target names its base: an entry without one makes the
whole value invalid (logged, nothing seeded), so a human-merged default
branch (`main`, `master`) becomes a target only when the seed names it.
Afterwards owners change the targets in Steer like any other; agents never
can. `auto_merge` inside `OMNIGENT_FACTORY_DELIVERY_SEED` still works for a
factory's first delivery seed; this seed wins in v1 when both name targets.
- **Autonomy and auto-merge per subtree (owners only).** An owner scopes
either setting to one stored node of the product tree (a goal `G-n`, a need
`O-n`, a feature `I-n` or a project slug) with the node's "Autonomy &
Expand Down
12 changes: 8 additions & 4 deletions omnigent/server/factory/background/delivery_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
:data:`~omnigent.server.factory.product_profile.ENVIRONMENT_SEED_ENV`) as one
seed version each, so what the server, the cockpit and the agents read from
the profile matches what they did before
(:func:`.product_profile.seed_existing`). A factory on a repo the seed does
(:func:`.product_profile.seed_existing`). The autonomy level and the
auto-merge targets (:data:`~omnigent.server.factory.product_profile.AUTO_MERGE_SEED_ENV`)
are seeded the same way while no version ever set them. A factory on a repo the seed does
not name is left alone. It runs in the background, one factory at a time
under its definition lock, and never again for a factory it marked. Each
live PL whose charter gains ``Environment`` lines gets them as a CHARTER
UPDATE, since its playbook now points there.
UPDATE, since its playbook now points there; so does each PL whose merge
policy line now says the server merges its PRs.
"""

from __future__ import annotations
Expand Down Expand Up @@ -59,7 +62,8 @@ async def seed_all(app_state: Any) -> list[str]:
continue
if root.id not in seeded:
seeded.append(root.id)
if seed is product_profile.ENVIRONMENT:
if seed in (product_profile.ENVIRONMENT, product_profile.AUTO_MERGE):
# Charter lines: the environment, and who merges PRs into a target base.
await _announce(app_state, deps, root, version)
return seeded

Expand All @@ -83,7 +87,7 @@ async def _announce(app_state: Any, deps: FactoryDeps, root: Any, version: int)
)
except Exception: # noqa: BLE001 - the seed stays; the PLs read it on their next brief
_logger.warning(
"factory %s: could not announce the seeded environment", root.id, exc_info=True
"factory %s: could not announce the seeded profile", root.id, exc_info=True
)


Expand Down
46 changes: 44 additions & 2 deletions omnigent/server/factory/product_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
(:data:`AUTONOMY_SEED_ENV`, ``{"<owner/repo>": {"level": "standard"}}``): a
factory on that repo starts at that level, or gets it once as a seed version
while no version ever set ``autonomy.level``; owners change it in Steer.

The auto-merge targets have their own seed (:data:`AUTO_MERGE_SEED_ENV`,
``{"<owner/repo>": [{"base": "staging"}]}``), so a deployment can hand them to
a factory whose delivery profile was seeded or set long ago: a factory on that
repo starts with them, or gets them once as a seed version (by the server,
never an agent) while no version ever set ``product.delivery.auto_merge``. A
factory whose owners set or cleared targets is never touched. Each target
names its base (a target without one seeds nothing), so a human-merged
default branch such as ``main`` is a target only when the seed says so; its
``repo`` is the key unless it names another. Owners change the targets in
Steer afterwards; agents never do.
"""

from __future__ import annotations
Expand Down Expand Up @@ -72,6 +83,10 @@
"""JSON ``{"<owner/repo>": {"level": "standard"}}``: the level that repo's factories start at."""
LABEL_AUTONOMY_SEED: Final = f"{LABEL_PREFIX}autonomy_seed"
"""On a factory root once its autonomy level was seeded (``v<n>``) or found set (``set``)."""
AUTO_MERGE_SEED_ENV: Final = "OMNIGENT_FACTORY_AUTO_MERGE_SEED"
"""JSON ``{"<owner/repo>": [{"base": "staging"}]}``: that repo's factories' merge targets."""
LABEL_AUTO_MERGE_SEED: Final = f"{LABEL_PREFIX}auto_merge_seed"
"""On a factory root once its auto-merge targets were seeded (``v<n>``) or found set (``set``)."""
_HISTORY_LIMIT: Final = 1000


Expand All @@ -87,6 +102,9 @@ class ProfileSeed:
:param repo_field: A field set to the seed's repo when the seed leaves it out.
:param fields: Only these fields of the block are seeded and checked for
"ever set" (``None``: the whole block).
:param list_field: A repo's value is this field's list (a list of objects,
each given the seed's repo as ``repo`` when it names none); an empty
list seeds nothing.
"""

env: str
Expand All @@ -95,6 +113,7 @@ class ProfileSeed:
label: str
repo_field: str | None = None
fields: tuple[str, ...] | None = None
list_field: str | None = None

def default(self) -> dict[str, Any]:
""":returns: The block's defaults."""
Expand All @@ -108,7 +127,16 @@ def default(self) -> dict[str, Any]:
AUTONOMY: Final = ProfileSeed(
AUTONOMY_SEED_ENV, "autonomy", AutonomyDef, LABEL_AUTONOMY_SEED, fields=("level",)
)
SEEDS: Final = (DELIVERY, ENVIRONMENT, AUTONOMY)
AUTO_MERGE: Final = ProfileSeed(
AUTO_MERGE_SEED_ENV,
SEED_PATH,
DeliveryDef,
LABEL_AUTO_MERGE_SEED,
fields=("auto_merge",),
list_field="auto_merge",
)
SEEDS: Final = (DELIVERY, ENVIRONMENT, AUTONOMY, AUTO_MERGE)
"""Applied in this order: a later seed's fields win in v1 (auto-merge over delivery)."""


def _default_delivery() -> dict[str, Any]:
Expand Down Expand Up @@ -140,12 +168,16 @@ def deployment_seeds(seed: ProfileSeed = DELIVERY) -> dict[str, dict[str, Any]]:
seeds: dict[str, dict[str, Any]] = {}
for key, fields in parsed.items():
repo = _norm_repo(key)
if seed.list_field is not None:
fields = _listed(seed.list_field, key, repo, fields)
if not repo or not isinstance(fields, Mapping):
raise ValueError(f"{key!r}: keys are owner/repo, values {seed.path} objects")
found = seed.model.model_validate(dict(fields))
values = found.model_dump(mode="json", exclude_unset=True)
if seed.fields is not None:
values = {k: v for k, v in values.items() if k in seed.fields}
if seed.list_field is not None and not values.get(seed.list_field):
continue
if values:
if seed.repo_field and seed.repo_field not in values:
values[seed.repo_field] = repo
Expand All @@ -161,6 +193,13 @@ def deployment_seeds(seed: ProfileSeed = DELIVERY) -> dict[str, dict[str, Any]]:
return {}


def _listed(field: str, key: object, repo: str, value: object) -> dict[str, Any]:
""":returns: ``{field: value}``, each item given *key* as its ``repo`` when it names none."""
if not isinstance(value, list) or not all(isinstance(i, Mapping) for i in value):
raise ValueError(f"{key!r}: values are lists of {field} objects")
return {field: [{"repo": str(key).strip(), **item} if repo else dict(item) for item in value]}


def seed_values(*repos: str, seed: ProfileSeed = DELIVERY) -> dict[str, Any]:
"""
:param repos: The factory's repos, its default repo first, e.g. ``("acme/app",)``.
Expand Down Expand Up @@ -231,7 +270,7 @@ def seed_existing(

:param deps: The factory stores.
:param root: The factory root.
:param seed: The block to seed (:data:`DELIVERY` or :data:`ENVIRONMENT`).
:param seed: The block to seed (one of :data:`SEEDS`).
:returns: The seed version, or ``None`` when nothing was stored.
"""
from omnigent.factory import LABEL_DEFAULT_REPO
Expand Down Expand Up @@ -321,11 +360,14 @@ def preview_domain(deps: FactoryDeps | None, factory_id: str | None) -> str | No
__all__ = [
"AUTONOMY",
"AUTONOMY_SEED_ENV",
"AUTO_MERGE",
"AUTO_MERGE_SEED_ENV",
"DELIVERY",
"DELIVERY_SEED_ENV",
"ENVIRONMENT",
"ENVIRONMENT_SEED_ENV",
"LABEL_AUTONOMY_SEED",
"LABEL_AUTO_MERGE_SEED",
"LABEL_DELIVERY_SEED",
"LABEL_ENVIRONMENT_SEED",
"SEEDS",
Expand Down
142 changes: 142 additions & 0 deletions tests/server/factory/test_auto_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,148 @@ async def test_an_agent_never_proposes_targets(
assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid)


# ── The deployment's auto-merge seed ─────────────────────────────────────────


def _seed_targets(monkeypatch: pytest.MonkeyPatch, value: Any) -> None:
monkeypatch.setenv(product_profile.AUTO_MERGE_SEED_ENV, json.dumps(value))


def _seed_now(cockpit: Cockpit) -> int | None:
root = cockpit.world.store.get_conversation(cockpit.fid)
assert root is not None
found = product_profile.seed_existing(cockpit.deps, root, product_profile.AUTO_MERGE)
definition.invalidate()
return found


def test_the_auto_merge_seed_names_each_base(monkeypatch: pytest.MonkeyPatch) -> None:
other = "sample-org/sample-docs"
_seed_targets(monkeypatch, {REPO: [{"base": BASE}, {"repo": other, "base": "main"}]})
assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {
REPO: {"auto_merge": [*TARGETS, {"repo": other, "base": "main"}]}
}
# A target without a base is never read as the default branch: nothing is seeded.
_seed_targets(monkeypatch, {REPO: [{"base": BASE}, {}]})
assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {}
_seed_targets(monkeypatch, {REPO: {"base": BASE}})
assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {}
# An empty list seeds nothing (never an empty version).
_seed_targets(monkeypatch, {REPO: []})
assert product_profile.deployment_seeds(product_profile.AUTO_MERGE) == {}


def test_a_new_factory_starts_with_the_seeded_targets(monkeypatch: pytest.MonkeyPatch) -> None:
from omnigent import factory as F

_seed_targets(monkeypatch, {REPO: [{"base": BASE}]})

doc = definition.seed_doc({F.LABEL_DEFAULT_REPO: REPO})
other = definition.seed_doc({F.LABEL_DEFAULT_REPO: "sample-org/other"})

assert doc["product"]["delivery"]["auto_merge"] == TARGETS
assert other["product"]["delivery"]["auto_merge"] == []


async def test_the_auto_merge_seed_applies_once_as_the_server(
cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch
) -> None:
# A factory from before the seed: its v1 exists already, its profile set otherwise.
resp = await put(
cockpit,
{"op": "replace", "path": "product.delivery.merge_policy", "value": "Owners (sample)"},
)
assert resp.status_code == 200, resp.text
_seed_targets(monkeypatch, {REPO: [{"base": BASE}]})

version = _seed_now(cockpit)
again = _seed_now(cockpit)

assert version is not None and again is None
row = cockpit.deps.factory_store.get_definition(cockpit.fid, version)
assert row is not None and (row.created_by, row.created_via) == ("server", "seed")
assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)}
# Only the targets are seeded; the rest of the profile stays the owners'.
assert definition.value(cockpit.deps, cockpit.fid, "product.delivery.merge_policy") == (
"Owners (sample)"
)
root = cockpit.world.store.get_conversation(cockpit.fid)
assert root is not None
assert root.labels[product_profile.LABEL_AUTO_MERGE_SEED] == f"v{version}"
# Idempotent across restarts too: the label stops the boot pass, and a
# changed seed never reaches a seeded factory.
_seed_targets(monkeypatch, {REPO: [{"base": "main"}]})
assert _seed_now(cockpit) is None
assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)}


async def test_the_auto_merge_seed_skips_a_factory_that_set_targets(
cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch
) -> None:
resp = await put(cockpit, {"op": "replace", "path": PATH, "value": TARGETS})
assert resp.status_code == 200, resp.text
_seed_targets(monkeypatch, {REPO: [{"base": "main"}]})

assert _seed_now(cockpit) is None

assert auto_merge.factory_targets(cockpit.deps, cockpit.fid).pairs == {(REPO, BASE)}
root = cockpit.world.store.get_conversation(cockpit.fid)
assert root is not None and root.labels[product_profile.LABEL_AUTO_MERGE_SEED] == "set"


async def test_the_auto_merge_seed_skips_a_factory_that_cleared_targets(
cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch
) -> None:
for value in (TARGETS, []):
resp = await put(cockpit, {"op": "replace", "path": PATH, "value": value})
assert resp.status_code == 200, resp.text
definition.invalidate()
assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid)
_seed_targets(monkeypatch, {REPO: [{"base": BASE}]})

assert _seed_now(cockpit) is None

assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid)


async def test_the_auto_merge_seed_skips_another_repo_and_adds_no_main(
cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch
) -> None:
_seed_targets(monkeypatch, {"sample-org/other": [{"base": BASE}]})
assert _seed_now(cockpit) is None
assert not auto_merge.factory_targets(cockpit.deps, cockpit.fid)
root = cockpit.world.store.get_conversation(cockpit.fid)
assert root is not None and product_profile.LABEL_AUTO_MERGE_SEED not in root.labels

# The seed names a test base only: the repo's default branch is never added with it.
_seed_targets(monkeypatch, {REPO: [{"base": "preview-main"}]})
assert _seed_now(cockpit) is not None
targets = auto_merge.factory_targets(cockpit.deps, cockpit.fid)
assert targets.pairs == {(REPO, "preview-main")}
assert not auto_merge.is_target(targets, REPO, "main")


async def test_the_boot_seed_tells_each_live_pl_the_server_merges(
cockpit: Cockpit, monkeypatch: pytest.MonkeyPatch
) -> None:
from omnigent.server.factory.background import delivery_seed
from tests.server.factory.projects_helpers import BASE as PROJECT_BASE
from tests.server.factory.projects_helpers import seed as seed_cockpit

seed_cockpit(cockpit)
# A factory from before the seed: its v1 exists already.
assert definition.definition_for(cockpit.deps, cockpit.fid, fresh=True) is not None
_seed_targets(monkeypatch, {REPO: [{"base": PROJECT_BASE}]})

assert await delivery_seed.seed_all(cockpit.app.state) == [cockpit.fid]
assert await delivery_seed.seed_all(cockpit.app.state) == []

pl = cockpit.world.id("pl")
[text] = [t for t in cockpit.mailer.texts_to(pl) if "CHARTER UPDATE" in t]
assert "by server (seed v" in text
assert f"PRs into {REPO}#{PROJECT_BASE} are merged by the factory's server" in text


async def test_approving_an_agent_proposal_never_widens_targets(cockpit: Cockpit) -> None:
# A proposal stored past the propose check (an older server filed it) still never applies.
row, _ = cockpit.store.create_proposal(
Expand Down
Loading