You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by mi...@apache.org on 2026/10/21 09:41:00 UTC
(hugegraph-ai) 04/06: fix(data/robustness): projection keyset backfill, builder store sidecars, demo env respect, admin lockout self-heal, bounded reads, sandbox stdout isolation
This is an automated email from the ASF dual-hosted git repository.
simon824 pushed a commit to branch ontogeny
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
commit 4e1e130e1b0f81507049dbf0e81404ce494e0b3d
Author: simon824 <si...@users.noreply.github.com>
AuthorDate: Wed Oct 21 16:54:01 2026 +0800
fix(data/robustness): projection keyset backfill, builder store sidecars, demo env respect, admin lockout self-heal, bounded reads, sandbox stdout isolation
- projection rebuild: keyset pagination by PK replaces the silent
10k-row truncation without ORDER BY (edges used to reference vertices
that were never written)
- builder: inline backing.store_spec sidecars are actually written to
stores/<name>.yaml now (the writer re-readed the already-parsed spec,
so saving an object with a new backing store failed validation)
- cli --demo: operator-preset env wins (setdefault) -- demo paths used
to clobber ONTOGENY_DB_DSN and lose edits outside the /data volume
- auth: demoting/disabling the last ACTIVE administrator is refused
(counted excluding the row itself); ensure_admin self-heals an
existing-but-demoted bootstrap account instead of crashing at boot
- api: bounded reads -- QueryBody.limit/offset and TraverseBody
depth/ids/path carry pydantic bounds; the query service and the
broker's sessionless search clamp limits too (the MCP surface builds
args without pydantic)
- sandbox: user print() no longer corrupts the JSON protocol stream --
child binds the protocol to the real stdout at startup and redirects
user stdout to stderr (RPC included); the parent turns stray non-JSON
lines into SandboxError instead of an uncaught JSONDecodeError
10 new regression tests; full suite 582 passed
---
ontogeny/server/ontogeny/agent/broker.py | 2 +-
ontogeny/server/ontogeny/api/schemas.py | 14 +-
ontogeny/server/ontogeny/auth/service.py | 38 ++++-
ontogeny/server/ontogeny/builder_ops.py | 35 ++--
ontogeny/server/ontogeny/cli.py | 5 +-
ontogeny/server/ontogeny/engine/service.py | 3 +
ontogeny/server/ontogeny/functions/child.py | 19 ++-
ontogeny/server/ontogeny/functions/sandbox.py | 7 +-
ontogeny/server/ontogeny/projection/worker.py | 28 +++-
ontogeny/tests/test_pr380_data.py | 228 ++++++++++++++++++++++++++
10 files changed, 336 insertions(+), 43 deletions(-)
diff --git a/ontogeny/server/ontogeny/agent/broker.py b/ontogeny/server/ontogeny/agent/broker.py
index f024feed..274f4156 100644
--- a/ontogeny/server/ontogeny/agent/broker.py
+++ b/ontogeny/server/ontogeny/agent/broker.py
@@ -648,7 +648,7 @@ class AgentBroker:
if tdef.kind == "meta":
return "ok", sc.compiled.to_meta(), {}
if tdef.kind == "search":
- limit = int(args.get("limit") or 20)
+ limit = max(1, min(int(args.get("limit") or 20), 1000))
started = time.perf_counter()
try:
out = await sc.query.query(session, tdef.target, principal,
diff --git a/ontogeny/server/ontogeny/api/schemas.py b/ontogeny/server/ontogeny/api/schemas.py
index ef60ac83..def8e96b 100644
--- a/ontogeny/server/ontogeny/api/schemas.py
+++ b/ontogeny/server/ontogeny/api/schemas.py
@@ -22,7 +22,7 @@ from __future__ import annotations
from typing import Any, Literal
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
# ------------------------------------------------------------------ schemas
@@ -30,8 +30,10 @@ from pydantic import BaseModel
class QueryBody(BaseModel):
filter: Any = None
sort: Any = None
- limit: int = 100
- offset: int = 0
+ # bounded: caller-supplied limits used to be uncapped, letting an
+ # unauthenticated query load entire tables into memory
+ limit: int = Field(100, ge=1, le=1000)
+ offset: int = Field(0, ge=0, le=100_000)
links: list[str] | None = None
via: str | None = None
@@ -67,9 +69,9 @@ class FunctionBody(BaseModel):
class TraverseBody(BaseModel):
start_type: str
- start_ids: list[str]
- path: list[dict[str, Any]]
- max_depth: int = 3
+ start_ids: list[str] = Field(max_length=50)
+ path: list[dict[str, Any]] = Field(max_length=6)
+ max_depth: int = Field(3, ge=1, le=6)
class EvolveProposalBody(BaseModel):
diff --git a/ontogeny/server/ontogeny/auth/service.py b/ontogeny/server/ontogeny/auth/service.py
index ff0fc1ca..fb0a9c5e 100644
--- a/ontogeny/server/ontogeny/auth/service.py
+++ b/ontogeny/server/ontogeny/auth/service.py
@@ -38,7 +38,7 @@ from typing import Any
from sqlalchemy import delete, func, select
-from ..errors import AuthenticationError, DSLValidationError, NotFoundError
+from ..errors import ConflictError, AuthenticationError, DSLValidationError, NotFoundError
from .models import SessionRow, UserRow
from .passwords import hash_password, new_token, token_fingerprint, verify_password
@@ -106,6 +106,7 @@ class AuthService:
row = await self._user(session, username)
if row is None:
raise NotFoundError(f"user {username!r} not found")
+ was_active_admin = row.is_admin and row.status == "active"
if "display" in patch and patch["display"] is not None:
row.display = str(patch["display"])
if patch.get("roles") is not None:
@@ -120,6 +121,21 @@ class AuthService:
row.status = patch["status"]
if patch.get("is_admin") is not None:
row.is_admin = bool(patch["is_admin"])
+ # demoting or disabling the LAST active administrator locks the
+ # deployment out after the next restart; refuse it outright
+ is_still_active_admin = row.is_admin and row.status == "active"
+ if was_active_admin and not is_still_active_admin:
+ other_active_admins = (await session.execute(
+ select(func.count()).select_from(UserRow).where(
+ UserRow.is_admin.is_(True), UserRow.status == "active",
+ UserRow.id != row.id,
+ )
+ )).scalar_one()
+ if other_active_admins == 0:
+ raise ConflictError(
+ f"cannot demote or disable {username!r}: it is the last active administrator",
+ details={"username": username},
+ )
if patch.get("password"):
self._check_password(patch["password"])
row.password_hash = hash_password(patch["password"])
@@ -240,13 +256,31 @@ class AuthService:
"""
async with self.sc.sessionmaker() as session:
existing = (await session.execute(
- select(func.count()).select_from(UserRow).where(UserRow.is_admin.is_(True))
+ select(func.count()).select_from(UserRow).where(
+ UserRow.is_admin.is_(True), UserRow.status == "active"
+ )
)).scalar_one()
if existing:
return None
username = os.environ.get("ONTOGENY_ADMIN_USER", "admin")
password = os.environ.get("ONTOGENY_ADMIN_PASSWORD") or "ontogeny@2026"
+ # self-heal instead of crashing: the bootstrap account may already
+ # exist as non-admin or disabled (demoted by a buggy older path), and
+ # create_user would raise "username exists" on the next restart
+ async with self.sc.sessionmaker() as session:
+ row = await self._user(session, username)
+ if row is not None:
+ row.is_admin = True
+ row.status = "active"
+ row.roles = sorted(set(row.roles) | {"admin"})
+ await session.commit()
+ log.warning(
+ "re-activated the bootstrap administrator %r (it existed but was "
+ "not an active admin); set ONTOGENY_ADMIN_PASSWORD and change it",
+ username,
+ )
+ return self._view(row)
user = await self.create_user(
username=username, password=password, display="平台管理员",
roles=["admin"], is_admin=True,
diff --git a/ontogeny/server/ontogeny/builder_ops.py b/ontogeny/server/ontogeny/builder_ops.py
index a656c0ad..6c0af5c7 100644
--- a/ontogeny/server/ontogeny/builder_ops.py
+++ b/ontogeny/server/ontogeny/builder_ops.py
@@ -107,34 +107,31 @@ async def builder_save(sc, resources: list[dict[str, Any]], *, deletes: list[str
import yaml as _yaml_mod
# ObjectType store sidecars: the console's data-mount editor declares the
- # source INLINE as backing.store_spec ({kind, connection}) because a bare
- # store NAME would be a placeholder, not a source. Materialize it as
- # stores/<store>.yaml (same pattern as PolicySet cedar sidecars) and strip
- # it from the object YAML -- BackingSpec has no such field on disk.
- for res in parsed:
- if res.kind != "ObjectType":
- continue
- backing = (res.spec.get("backing") if isinstance(res.spec, dict) else None) or {}
- store_spec = backing.get("store_spec")
- if not isinstance(store_spec, dict):
- continue
- store_name = str(backing.get("store") or "").strip()
- kind = str(store_spec.get("kind") or "").strip()
- connection = str(store_spec.get("connection") or "").strip()
+ # source INLINE as backing.store_spec ({kind, connection}); the first pass
+ # (above) strips it before the pydantic parse because BackingSpec has no
+ # such field on disk. THIS pass materializes each collected sidecar as
+ # stores/<name>.yaml -- it used to re-read res.spec (already parsed, never
+ # a dict), so the files were never written and publishing an object with a
+ # new backing store failed validation on the missing store.
+ for store_name, kind, connection in store_sidecars:
+ import yaml as _yaml
+
+ from ontogeny.errors import DSLValidationError
+
if not store_name or not kind or not connection:
raise DSLValidationError(
- f"ObjectType/{res.metadata.name}: incomplete data source — "
- "store name, source type and connection are all required",
+ "ObjectType backing.store_spec: store name, source type and "
+ "connection are all required",
details={"store": store_name, "kind": kind},
)
- access = "read-only" if kind == "csv" else "read-write"
stores_dir = pkg_dir / "stores"
stores_dir.mkdir(parents=True, exist_ok=True)
(stores_dir / f"{store_name}.yaml").write_text(
- _yaml_mod.safe_dump({
+ _yaml.safe_dump({
"apiVersion": "ontogeny/v1", "kind": "Store",
"metadata": {"name": store_name},
- "spec": {"type": kind, "connection": connection, "access": access},
+ "spec": {"type": kind, "connection": connection,
+ "access": "read-only" if kind == "csv" else "read-write"},
}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
diff --git a/ontogeny/server/ontogeny/cli.py b/ontogeny/server/ontogeny/cli.py
index 26e65e6b..4b1392e3 100644
--- a/ontogeny/server/ontogeny/cli.py
+++ b/ontogeny/server/ontogeny/cli.py
@@ -253,8 +253,11 @@ def serve(
paths = prepare_demo(data_dir, package=package or DEFAULT_PACKAGE,
reseed=reseed, direct=direct)
+ # operator-provided env wins: --demo used to clobber ONTOGENY_DB_DSN
+ # (and friends), so edits/promotions landed outside the /data volume
+ # and vanished on container recreation
for k, v in paths.as_env().items():
- os.environ[k] = v
+ os.environ.setdefault(k, v)
typer.secho(f"demo data dir: {paths.root.resolve()}", fg=typer.colors.CYAN)
if direct:
typer.secho(f" serving the domain IN PLACE: {paths.package_root}\n"
diff --git a/ontogeny/server/ontogeny/engine/service.py b/ontogeny/server/ontogeny/engine/service.py
index aa0fd2f3..a330cc79 100644
--- a/ontogeny/server/ontogeny/engine/service.py
+++ b/ontogeny/server/ontogeny/engine/service.py
@@ -83,6 +83,9 @@ class QueryService:
links: list[str] | None = None,
) -> dict[str, Any]:
started = time.perf_counter()
+ # belt over the schema bounds: the MCP surface builds args without
+ # pydantic, so clamp here too
+ limit = max(1, min(int(limit or 100), 1000))
rows, total = await self.repo.query(
session, object_type, filt=filt, sort=sort, limit=limit, offset=offset,
)
diff --git a/ontogeny/server/ontogeny/functions/child.py b/ontogeny/server/ontogeny/functions/child.py
index 9d1c4f05..36ace4f7 100644
--- a/ontogeny/server/ontogeny/functions/child.py
+++ b/ontogeny/server/ontogeny/functions/child.py
@@ -30,6 +30,7 @@ Protocol (one JSON object per line):
"""
from __future__ import annotations
+import contextlib
import importlib.util
import json
import sys
@@ -45,8 +46,8 @@ class _OO:
def _rpc(self, method: str, params: dict):
self._id += 1
- sys.stdout.write(json.dumps({"type": "rpc", "id": self._id, "method": method, "params": params}) + "\n")
- sys.stdout.flush()
+ _PROTO.write(json.dumps({"type": "rpc", "id": self._id, "method": method, "params": params}) + "\n")
+ _PROTO.flush()
line = sys.stdin.readline()
if not line:
raise RuntimeError("sandbox parent closed the pipe")
@@ -87,9 +88,12 @@ def _load(module_path: str, func_name: str):
return mod, fn, None
+_PROTO = sys.stdout # the protocol stream, bound BEFORE any user-code redirect
+
+
def _emit(payload: dict) -> None:
- sys.stdout.write(json.dumps(payload, default=str) + "\n")
- sys.stdout.flush()
+ _PROTO.write(json.dumps(payload, default=str) + "\n")
+ _PROTO.flush()
def main() -> None:
@@ -103,7 +107,12 @@ def main() -> None:
try:
mode = init.get("type", "invoke")
if mode == "invoke":
- _emit({"type": "result", "value": fn(**(init.get("params") or {}))})
+ # user stdout must never touch the protocol stream (a single
+ # print() used to corrupt the JSON-RPC framing and 500 the whole
+ # call); the parent additionally treats stray non-JSON lines as
+ # SandboxError
+ with contextlib.redirect_stdout(sys.stderr):
+ _emit({"type": "result", "value": fn(**(init.get("params") or {}))})
elif mode == "map":
# derivation batch: the runner loops per row inside this process
# (one spawn for the whole batch); ontogeny.query stays available per row
diff --git a/ontogeny/server/ontogeny/functions/sandbox.py b/ontogeny/server/ontogeny/functions/sandbox.py
index 347d9b50..309592e2 100644
--- a/ontogeny/server/ontogeny/functions/sandbox.py
+++ b/ontogeny/server/ontogeny/functions/sandbox.py
@@ -242,7 +242,12 @@ class FunctionSandbox:
line = await proc.stdout.readline()
if not line:
raise SandboxError("sandbox exited before returning a result")
- msg = json.loads(line)
+ try:
+ msg = json.loads(line)
+ except json.JSONDecodeError as exc:
+ raise SandboxError(
+ f"protocol stream corrupted by non-JSON output: {line[:200]!r}"
+ ) from exc
if msg.get("type") == "rpc":
method = msg.get("method")
if method == "llm":
diff --git a/ontogeny/server/ontogeny/projection/worker.py b/ontogeny/server/ontogeny/projection/worker.py
index 6b3893e9..9a70f85c 100644
--- a/ontogeny/server/ontogeny/projection/worker.py
+++ b/ontogeny/server/ontogeny/projection/worker.py
@@ -177,14 +177,26 @@ class ProjectionWorker:
counts = {"vertices": 0, "edges": 0}
for object_type in self.projection.spec.include.objects:
props_whitelist, pk = self._whitelist(object_type)
- rows, total = await repo.query(session, object_type, limit=10000)
- items = []
- for row in rows:
- item = {p: row.get(p) for p in props_whitelist}
- item[pk] = row.get(pk)
- items.append(item)
- await self.client.upsert_vertices(object_type, pk, items)
- counts["vertices"] += len(items)
+ # keyset pagination by PK: the old `limit=10000` without ORDER BY
+ # silently truncated every larger table and left edges pointing
+ # at vertices that were never written
+ batch = 500
+ last = None
+ while True:
+ filt = {"field": pk, "op": "gt", "value": last} if last is not None else None
+ rows, _total = await repo.query(
+ session, object_type, filt=filt, sort=[[pk, "asc"]], limit=batch,
+ )
+ if not rows:
+ break
+ items = []
+ for row in rows:
+ item = {p: row.get(p) for p in props_whitelist}
+ item[pk] = row.get(pk)
+ items.append(item)
+ await self.client.upsert_vertices(object_type, pk, items)
+ counts["vertices"] += len(items)
+ last = rows[-1].get(pk)
for link_name in self.projection.spec.include.links:
lnk = self.compiled.links[link_name]
pairs = await self._link_pairs(repo, session, link_name)
diff --git a/ontogeny/tests/test_pr380_data.py b/ontogeny/tests/test_pr380_data.py
new file mode 100644
index 00000000..2bfb9b47
--- /dev/null
+++ b/ontogeny/tests/test_pr380_data.py
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and
+# limitations under the License.
+"""PR #380 data/robustness-pack regression tests: projection backfill
+pagination, builder store sidecars, demo env respect, admin lockout
+self-heal, bounded reads, sandbox stdout isolation."""
+from __future__ import annotations
+
+import json
+
+import pytest
+
+
+class TestProjectionKeysetBackfill:
+ async def test_backfill_pages_past_batch_size(self, client):
+ """A 25-row table with batch forced to 10 must fully land in the graph."""
+ import datetime as dt
+
+ from ontogeny.projection.worker import ProjectionWorker
+
+ c, sc = client
+ await c.post("/api/v1/admin/sync/work-center")
+ # 4 seeded + 21 fresh rows = 25 > any single page
+ for i in range(21):
+ async with sc.sessionmaker() as s:
+ await sc.repo.action_insert(
+ s, "work-center",
+ {"work_center_id": f"WC-X{i:02d}", "name": f"Extra {i}",
+ "process": "assembly", "site": "plant-north",
+ "capacity_per_shift": 10, "shifts_per_day": 1, "status": "ACTIVE"},
+ dt.datetime.now(dt.timezone.utc),
+ )
+ await s.commit()
+
+ prj = next(iter(sc.compiled.projections.values()))
+ upserted: list[int] = []
+
+ class FakeClient:
+ async def upsert_vertices(self, label, pk, items):
+ upserted.append(len(items))
+
+ async def upsert_edges(self, *a, **k):
+ return None
+
+ worker = ProjectionWorker.__new__(ProjectionWorker)
+ worker.projection = prj
+ worker.compiled = sc.compiled
+ worker.client = FakeClient()
+ worker._link_pairs = ProjectionWorker._link_pairs.__get__(worker)
+ # 强制小批量:monkeypatch 掉常量
+ import ontogeny.projection.worker as w
+
+ original_query = sc.repo.query
+
+ async def small_query(session, object_type, **kw):
+ kw["limit"] = min(kw.get("limit", 10), 10)
+ return await original_query(session, object_type, **kw)
+
+ sc.repo.query = small_query
+ try:
+ async with sc.sessionmaker() as s:
+ expected = len(await sc.repo.alive_ids(s, "work-center"))
+ counts = await worker.rebuild(sc.repo, s)
+ finally:
+ sc.repo.query = original_query
+ assert counts["vertices"] == expected, "backfill must page through every row"
+ assert all(n <= 10 for n in upserted), "each page respects the batch bound"
+
+
+class TestBuilderStoreSidecars:
+ async def test_inline_store_spec_materializes_store_yaml(self, client):
+ c, sc = client
+ obj = {
+ "apiVersion": "ontogeny/v1", "kind": "ObjectType",
+ "metadata": {"name": "widget", "display": "Widget"},
+ "spec": {
+ "primaryKey": ["widget_id"],
+ "properties": {
+ "widget_id": {"type": "string", "display": "ID", "required": True},
+ "name": {"type": "string", "display": "Name"},
+ },
+ "backing": {
+ "store": "widget-src",
+ "mode": "materialized",
+ "source": {"table": "widgets"},
+ "store_spec": {"kind": "sqlite",
+ "connection": "sqlite:///widgets.db"},
+ },
+ },
+ }
+ r = await c.post("/api/v1/admin/builder/save", json={"resources": [obj]})
+ assert r.status_code == 200, r.text
+ import pathlib
+
+ store_yaml = pathlib.Path(sc.package_root) / "stores" / "widget-src.yaml"
+ assert store_yaml.is_file(), "the inline store sidecar must be written"
+ assert "sqlite" in store_yaml.read_text(encoding="utf-8")
+
+
+class TestDemoEnvRespect:
+ def test_as_env_does_not_clobber_operator_values(self, tmp_path, monkeypatch):
+ from ontogeny.demo.bootstrap import prepare_demo
+
+ monkeypatch.setenv("ONTOGENY_DB_DSN", "sqlite+aiosqlite:////data/operator.db")
+ paths = prepare_demo(tmp_path / "demo", package="domains/product-manufacturing")
+ env = paths.as_env()
+ # as_env itself only carries defaults; the respect happens in cli via
+ # setdefault -- assert the contract here and simulate the cli line
+ for k, v in env.items():
+ os_setdefault = {k: v}
+ import os
+
+ assert os.environ["ONTOGENY_DB_DSN"].endswith("/operator.db")
+ # the exact cli behaviour under test:
+ for k, v in env.items():
+ os.environ.setdefault(k, v)
+ assert os.environ["ONTOGENY_DB_DSN"].endswith("/operator.db"), \
+ "operator-preset env must survive a --demo boot"
+
+
+class TestAdminLockout:
+ async def test_cannot_demote_last_admin(self, client):
+ from ontogeny.errors import ConflictError
+
+ c, sc = client
+ with pytest.raises(ConflictError):
+ await sc.auth.update_user("admin", {"is_admin": False})
+ with pytest.raises(ConflictError):
+ await sc.auth.update_user("admin", {"status": "disabled"})
+
+ async def test_second_admin_can_be_demoted(self, client):
+ c, sc = client
+ await sc.auth.create_user(username="admin2", password="password123",
+ roles=["admin"], is_admin=True)
+ out = await sc.auth.update_user("admin2", {"is_admin": False})
+ assert out["is_admin"] is False
+
+ async def test_ensure_admin_self_heals_disabled_bootstrap(self, client):
+ c, sc = client
+ # 直接把 root 停用(绕过守卫模拟历史脏数据),ensure_admin 应复活它而非崩溃
+ from sqlalchemy import update
+
+ from ontogeny.auth.models import UserRow
+
+ async with sc.sessionmaker() as s:
+ await s.execute(update(UserRow).values(status="disabled"))
+ await s.commit()
+ user = await sc.auth.ensure_admin()
+ assert user is not None and user["is_admin"] is True and user["status"] == "active"
+
+
+class TestBoundedReads:
+ async def test_query_limit_rejected_over_bound(self, client):
+ c, _ = client
+ r = await c.post("/api/v1/objects/work-center/query", json={"limit": 100000})
+ assert r.status_code == 422
+
+ async def test_traverse_depth_rejected_over_bound(self, client):
+ c, _ = client
+ r = await c.post("/api/v1/graph/traverse", json={
+ "start_type": "production-order", "start_ids": ["PO-1001"],
+ "path": [], "max_depth": 10,
+ })
+ assert r.status_code == 422
+
+ async def test_readonly_tool_limit_clamped(self, client):
+ c, sc = client
+ await c.post("/api/v1/admin/sync/work-center")
+ out = await sc.agents.read_only_tool("search_work_center", None, {"limit": 999999})
+ assert out["outcome"] == "ok" and len(out["detail"]["objects"]) <= 1000
+
+
+class TestSandboxStdoutIsolation:
+ async def test_print_does_not_break_protocol(self, tmp_path, golden_pkg_path):
+ import shutil
+
+ pkg = tmp_path / "pkg"
+ shutil.copytree(golden_pkg_path, pkg)
+ (pkg / "functions" / "chatty.py").write_text(
+ "def say(what):\n"
+ " print('user-side noise:', what)\n"
+ " return {'said': what}\n",
+ encoding="utf-8",
+ )
+ import yaml as _yaml
+
+ doc = _yaml.safe_load((pkg / "functions" / "capacity-check.yaml").read_text(encoding="utf-8"))
+ doc["metadata"]["name"] = "chatty"
+ doc["spec"]["entry"] = "chatty.py:say"
+ doc["spec"]["parameters"] = {"what": {"type": "string", "display": "What", "required": True}}
+ doc["spec"]["capabilities"] = []
+ doc["spec"].pop("returns", None)
+ (pkg / "functions" / "chatty.yaml").write_text(
+ _yaml.safe_dump(doc, allow_unicode=True, sort_keys=False), encoding="utf-8")
+
+ from ontogeny.config import Settings
+ from ontogeny.core import load_package
+ from ontogeny.db import init_schema, make_engine, make_sessionmaker
+ from ontogeny.functions.sandbox import FunctionSandbox
+ from ontogeny.registry.store import RegistryService
+
+ settings = Settings(env={"ERP_DSN": "sqlite+aiosqlite:///:memory:"})
+ engine = make_engine("sqlite+aiosqlite:///:memory:")
+ await init_schema(engine)
+ maker = make_sessionmaker(engine)
+ async with maker() as s:
+ compiled = await RegistryService().publish(s, load_package(str(pkg)))
+ await s.commit()
+ fn = compiled.functions["chatty"]
+ from ontogeny.stores import ObjectRepository
+
+ sandbox = FunctionSandbox(compiled, ObjectRepository(compiled), timeout_s=15)
+ async with maker() as s:
+ value = await sandbox.run(s, fn, {"what": "hello"})
+ assert value == {"said": "hello"}