From 9c412d617529c697cb7384e58afce3e4b88be28d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 10:20:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(org):=20bidirectional=20asset=20bus=20?= =?UTF-8?q?=E2=80=94=20ingest=20into=20managed=20control=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a managed organization runtime, OPENXYOS_BASE_URL, or org-os/runtime.json is present, loop run and assets apply POST ingest into that control plane for the current org workspace tenant (remote_applied=true). Without a runtime URL, keep mirror-only behavior and do not start Node. Colleagues stay registered as org- agents. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + README.md | 6 +- dashboard/src/locales/en.json | 2 +- dashboard/src/locales/zh.json | 2 +- .../src/pages/Organization/index.test.tsx | 2 +- docs/asset-loop.md | 13 +- src/octop/api/routers/org_module.py | 26 ++-- src/octop/cli/commands/org.py | 20 +-- src/octop/i18n/en.json | 4 +- src/octop/i18n/zh.json | 4 +- src/octop/modules/org_os/apply/apply.py | 26 ++-- src/octop/modules/org_os/apply/client.py | 15 ++- src/octop/modules/org_os/assets/pack.py | 2 +- src/octop/modules/org_os/empower.py | 2 +- src/octop/modules/org_os/loop/run.py | 14 ++- src/octop/modules/org_os/managed_runtime.py | 117 +++++++++++++++++- src/octop/modules/org_os/notes.py | 4 +- src/octop/modules/org_os/service.py | 22 ++++ tests/unit/test_openxyos_apply.py | 38 +++++- tests/unit/test_org_loop.py | 35 ++++++ tests/unit/test_org_module.py | 45 +++++++ tests/unit/test_org_notes.py | 4 +- 22 files changed, 346 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1746206..f8f56190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### 新增 +- Organization P1.1(双向资产总线):`freeos org loop run` / `POST /api/org-module/loop/run` / `assets apply` 在托管组织运行时、`OPENXYOS_BASE_URL` 或 `{FREEOS_HOME}/org-os/runtime.json` 可用时,把资产包 ingest 进当前**组织工作区租户**(`remote_applied=true`),不再只写 `openxyos-mirror/`。无运行时地址时仍为镜像-only,且不拉起 Node。回流后同事仍登记为 `org-` 聊天智能体。FreeOS 工作室账号与组织房间身份分开。说明见 `docs/asset-loop.md`。 - P0.2:openXYOS → FreeOS 能力迁移图(`docs/org-capability-migration-map.md` / `.zh-CN.md` + `docs/org-capability-migration-map.json`)。按域记录原生宿主 / org-ui 切片 / 托管 Node iframe / 可选边车 / 仅导出;波次为资产总线 → 导出 → 双身份 UX → 本地模型/知识库 → 拆除 Node。不改运行时。 - Organization Phase 5(默认安装器瘦身):Windows/macOS/Linux 默认包装与 Docker Compose 保持 **单进程 FreeOS + 宿主内 Organization**,不捆绑、不解压、不自动拉起 openXYOS Node。`FREEOS_ORG_SIDECAR=1` 与 `SHIP_OPENXYOS_RUNTIME=1` 仍是可选高级路径。文档对齐 #55 / ADR 001 / ADR 003 / `docs/org-export.md`。`modules/openxyos` 保留给导出与开发,不是默认运行时。 - Organization Phase 4(独立站导出起步):`freeos org export-standalone --out ` 从 `dashboard/src/org-ui` 生成可运行的 Vite 包(OpenApp 风格路由、本地 JWT IdentityBridge `openxyos.standalone.jwt`、登录页、Docker / docker-compose、`server/proxy.mjs` 把 `/api` 反代到 FreeOS `FREEOS_UPSTREAM`)。Chat 不导出。默认安装器零 Node(Phase 5)。说明见 `docs/org-export.md`。 diff --git a/README.md b/README.md index 5e312059..4aef9351 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ For the exact license split, notices and trademark-safe wording, see [NOTICE](NO ## Run the self-growth loop -This is the product path. One command compiles a blueprint into a colleague, promotes it `draft → market → recruit → shadow → active`, registers a FreeOS chat agent, publishes an asset pack, applies that pack onto openXYOS-shaped surfaces as department employees (live HTTP when `OPENXYOS_BASE_URL` is set, otherwise a durable local mirror), imports the control plane back, and proves governance still **blocks** high-risk tools. +This is the product path. One command compiles a blueprint into a colleague, promotes it `draft → market → recruit → shadow → active`, registers a FreeOS chat agent, publishes an asset pack, applies that pack onto openXYOS-shaped surfaces as department employees (live HTTP when a managed runtime / `OPENXYOS_BASE_URL` / `org-os/runtime.json` is present, otherwise a durable local mirror), imports the control plane back, and proves governance still **blocks** high-risk tools. ```bash git clone https://github.com/XYAIStudio/FreeOS.git @@ -92,8 +92,8 @@ uv run pytest tests/e2e/test_org_growth_loop.py tests/unit/test_org_loop.py test | When | What happens | |---|---| -| `OPENXYOS_BASE_URL` unset / sidecar down | Uses `tests/fixtures/org-loop/` and writes `{FREEOS_HOME}/openxyos-mirror/` | -| `OPENXYOS_BASE_URL` (or `FREEOS_ORG_SIDECAR_URL`) set | Also POST `/api/freeos/ingest` (employees, talent, plugins, skills) when the sidecar is healthy | +| No runtime URL | Uses `tests/fixtures/org-loop/` and writes `{FREEOS_HOME}/openxyos-mirror/` (`remote_applied=false`; Node is not started) | +| Managed runtime, `OPENXYOS_BASE_URL`, `FREEOS_ORG_SIDECAR_URL`, or `{FREEOS_HOME}/org-os/runtime.json` | Also POST `/api/freeos/ingest` for the current org workspace tenant (`remote_applied=true`) | High-risk tools (`outbound`, `delete`, `pay`, `prod`) are default-denied in the host tool path (`OrgGovernanceMiddleware` + `xyos-governance-mcp`). `execute` stays false until `freeos org governance approve ` and the agent re-checks with the same args. diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 8919a0d0..70a41b82 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -179,7 +179,7 @@ "assembleNext": "Next: open Conversations and invite colleagues to a group. Unassigned helpers stay on Experts.", "assemblePreviewExperts": "Opening Experts so you can select the imported colleagues.", "packReceipt": "Packed and pushed to the signed-in openXYOS tenant.", - "packLanded": "Signed-in tenant {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}", + "packLanded": "Organization workspace {{tenant}}: employees {{employees}}, talent {{talent}}, skills {{skills}}, plugins {{plugins}}", "packPreviewEmployees": "The preview now opens Employees so you can confirm the result.", "navWorkbench": "Workbench", "navWorkspace": "Workspace", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 69d5dd4f..45758399 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -179,7 +179,7 @@ "assembleNext": "下一步:打开「对话」建群,把同事拉进来。还没入编的帮手在「专家」页。", "assemblePreviewExperts": "正在打开「专家」页,导入的同事可在那里选用。", "packReceipt": "已打包并推送到当前登录的 openXYOS 租户。", - "packLanded": "当前登录租户 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}", + "packLanded": "本组织工作区 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}", "packPreviewEmployees": "右侧预览已打开员工页,可直接核对。", "navWorkbench": "工作台", "navWorkspace": "总览", diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 33c44a3b..39332626 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -363,7 +363,7 @@ describe("OrganizationPage", () => { mirror_dir: "/tmp/mirror", remote_applied: false, mirrored: true, - notes: ["OPENXYOS_BASE_URL unset; applied to local mirror only"], + notes: ["OPENXYOS_BASE_URL / runtime.json unset; applied to local mirror only"], }, }); diff --git a/docs/asset-loop.md b/docs/asset-loop.md index 7052bc22..30a853ef 100644 --- a/docs/asset-loop.md +++ b/docs/asset-loop.md @@ -26,7 +26,7 @@ That path: - compiles colleagues and promotes `draft → market → recruit → shadow → active` - **registers real FreeOS agents** (`org-`) so they appear in chat routing - publishes `freeos.asset-pack.v1` -- **applies** the pack to openXYOS via `/api/freeos/ingest` (shared ingest token, no login wall) when the sidecar is healthy, always a durable `{FREEOS_HOME}/openxyos-mirror/` +- **applies** the pack via `/api/freeos/ingest` when a managed organization runtime, `OPENXYOS_BASE_URL`, or `{FREEOS_HOME}/org-os/runtime.json` is available (always a durable `{FREEOS_HOME}/openxyos-mirror/` as well). Ingest uses the **current org workspace tenant**, not a hardcoded `1`. Without a runtime URL the loop stays mirror-only and does not start Node. - imports the applied surfaces back - proves a high-risk tool is blocked (`execute=false`) @@ -86,7 +86,15 @@ uv run freeos org employee export-profile my-analyst --tenant-id 1 ``` `assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`. -`assets apply` POSTs to `/api/freeos/ingest` when the sidecar is reachable and always writes `{FREEOS_HOME}/openxyos-mirror//`. Ingest targets the **signed-in openXYOS tenant** (the embed login), so employees / talent / skills / plugins show in the normal list UIs. +`assets apply` POSTs to `/api/freeos/ingest` when a control-plane URL is present (`OPENXYOS_BASE_URL`, `FREEOS_ORG_SIDECAR_URL`, or `{FREEOS_HOME}/org-os/runtime.json` from the managed runtime) and always writes `{FREEOS_HOME}/openxyos-mirror//`. Ingest follows the **org workspace tenant** (config / `FREEOS_ORG_TENANT_ID` / the organization room), so department employees, talent, skills, and plugins land in that workspace. FreeOS studio accounts stay separate from the org room. With no runtime URL, apply is mirror-only and does not start Node. + +## Host ↔ org asset bus + +| Direction | What moves | +|---|---| +| FreeOS → org | Asset pack apply / `POST /api/org-module/loop/run` ingest employees, talent, skills, plugins, MCP into the control plane when a runtime URL exists (`remote_applied=true`) | +| Org → FreeOS | Import + spawn registers chat-addressable colleagues as `org-` agents | +| No runtime | Same loop; durable `openxyos-mirror/` only; `remote_applied=false` | ## Where files live @@ -102,6 +110,7 @@ uv run freeos org employee export-profile my-analyst --tenant-id 1 | Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` | | Asset pack | `{FREEOS_HOME}/asset-packs/latest/` | | Control-plane mirror | `{FREEOS_HOME}/openxyos-mirror//` | +| Managed runtime origin | `{FREEOS_HOME}/org-os/runtime.json` | | Loop proof | `{FREEOS_HOME}/asset-packs/loop-proof.json` | ## High-risk actions diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 699518c2..03c49066 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -69,6 +69,13 @@ def _service(server: OctopServer) -> OrgModuleService: return org_module_from_paths(server.paths) +def _organization_id(user: Any) -> int | None: + raw = getattr(user, "organization_id", None) + if isinstance(raw, int) and raw > 0: + return raw + return None + + def _plane_counts(server: OctopServer, user: Any) -> dict[str, int]: services = getattr(server, "services", None) user_id = getattr(user, "id", None) @@ -639,8 +646,8 @@ async def import_assets( try: imported = import_openxyos_assets( service.home, - tenant_id=body.tenant_id or service.tenant_id() or "default", - sidecar_url=service.sidecar_url(), + tenant_id=service.workspace_tenant_id(body.tenant_id), + sidecar_url=service.explicit_sidecar_url(), catalog=body.catalog, blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, policies_path=Path(body.policies_path) if body.policies_path else None, @@ -658,7 +665,7 @@ class AssetApplyBody(BaseModel): class LoopRunBody(BaseModel): - tenant_id: str = "1" + tenant_id: str = "" blueprint_path: str | None = None policies_path: str | None = None base_url: str = "" @@ -673,7 +680,7 @@ class EmployeeSpawnBody(BaseModel): async def apply_assets( body: AssetApplyBody, server: OctopServer = Depends(get_server), - _user: Any = Depends(require_permission("plugins")), + user: Any = Depends(require_permission("plugins")), ) -> dict[str, Any]: from pathlib import Path @@ -685,8 +692,10 @@ async def apply_assets( return apply_asset_pack( dest, home=service.home, - tenant_id=body.tenant_id or service.tenant_id() or "default", - base_url=body.base_url, + tenant_id=service.workspace_tenant_id( + body.tenant_id, organization_id=_organization_id(user) + ), + base_url=body.base_url or service.explicit_sidecar_url(), ).to_dict() except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -725,12 +734,15 @@ async def run_loop( try: proof = run_growth_loop( service.home, - tenant_id=body.tenant_id or service.tenant_id() or "1", + tenant_id=service.workspace_tenant_id( + body.tenant_id, organization_id=_organization_id(user) + ), blueprint_path=Path(body.blueprint_path) if body.blueprint_path else None, policies_path=Path(body.policies_path) if body.policies_path else None, sidecar_url=body.base_url or service.explicit_sidecar_url(), config_path=service.config_path, owner_user_id=int(getattr(user, "id", 0) or 0) or None, + organization_id=_organization_id(user), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index f4b463e5..065fe0ac 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -344,8 +344,8 @@ def assets_import( service = _service() imported = import_openxyos_assets( service.home, - tenant_id=tenant_id or service.tenant_id() or "default", - sidecar_url=service.sidecar_url(), + tenant_id=service.workspace_tenant_id(tenant_id), + sidecar_url=service.explicit_sidecar_url(), catalog=catalog, blueprint_path=blueprint_path, policies_path=policies_path, @@ -367,8 +367,8 @@ def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: result = apply_asset_pack( dest, home=service.home, - tenant_id=tenant_id or service.tenant_id() or "default", - base_url=base_url, + tenant_id=service.workspace_tenant_id(tenant_id), + base_url=base_url or service.explicit_sidecar_url(), ) click.echo(json.dumps(result.to_dict(), indent=2)) @@ -400,13 +400,17 @@ def org_loop() -> None: @org_loop.command("run") -@click.option("--tenant-id", default="1") +@click.option( + "--tenant-id", + default="", + help="Org workspace tenant. Defaults to config / FREEOS_ORG_TENANT_ID.", +) @click.option("--blueprint", "blueprint_path", type=click.Path(path_type=Path, exists=True)) @click.option("--policies", "policies_path", type=click.Path(path_type=Path, exists=True)) @click.option( "--base-url", default="", - help="openXYOS origin. Defaults to OPENXYOS_BASE_URL / FREEOS_ORG_SIDECAR_URL.", + help="openXYOS origin. Defaults to OPENXYOS_BASE_URL / runtime.json / FREEOS_ORG_SIDECAR_URL.", ) def loop_run( tenant_id: str, blueprint_path: Path | None, policies_path: Path | None, base_url: str @@ -417,10 +421,10 @@ def loop_run( service = _service() proof = run_growth_loop( service.home, - tenant_id=tenant_id or service.tenant_id() or "1", + tenant_id=service.workspace_tenant_id(tenant_id), blueprint_path=blueprint_path, policies_path=policies_path, - sidecar_url=base_url or service.sidecar_url(), + sidecar_url=base_url or service.explicit_sidecar_url(), config_path=service.config_path, ) click.echo(json.dumps(proof.to_dict(), indent=2)) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 4d4c26e2..abc3c133 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -731,7 +731,7 @@ "planes": "Data plane: colleagues / experts / skills / MCP / tasks. Control plane: department employees / blueprints / modules / governance.", "sidecar_offline": "openXYOS sidecar is offline — start it to sync blueprints and approvals.", "sidecar_reconnecting": "openXYOS sidecar is offline — reconnecting the install-time local console.", - "mirror_only": "OPENXYOS_BASE_URL unset; applied to local mirror only.", + "mirror_only": "No control-plane URL; applied to local mirror only.", "local_mirror_short": "Local mirror is the durable record.", "imported_export": "imported live control-plane export", "export_unreachable": "sidecar export unreachable; using catalog/blueprint fallback", @@ -752,7 +752,7 @@ "loop_skip_already": "skipped promotion for {slug}: already {lifecycle}", "loop_skip_illegal": "skipped illegal transition for {slug}: cannot move {current} → {target}", "loop_skip_unknown": "skipped promotion for {slug}: unknown colleague", - "visible_on_ui_tenant": "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", + "visible_on_ui_tenant": "Assets land on this organization's Employees, Talent, Skills, and Plugins lists.", "imported_skill": "imported openXYOS skill {slug}", "imported_plugin": "imported openXYOS plugin {slug}", "imported_mcp": "imported openXYOS MCP {slug}" diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index aa259ae8..7c003096 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -731,7 +731,7 @@ "planes": "数据面:同事 / 专家 / 技能 / MCP / 任务。控制面:部门员工 / 蓝图 / 模块 / 治理。", "sidecar_offline": "openXYOS 边车离线 — 启动后才能同步蓝图与审批。", "sidecar_reconnecting": "openXYOS 边车离线 — 正在自动连接安装期本机控制台。", - "mirror_only": "未设置 OPENXYOS_BASE_URL;仅写入本地镜像。", + "mirror_only": "未配置控制面地址;仅写入本地镜像。", "local_mirror_short": "本地镜像是可靠记录。", "imported_export": "已导入在线控制面导出", "export_unreachable": "边车导出不可达;回退到目录/蓝图", @@ -752,7 +752,7 @@ "loop_skip_already": "已跳过 {slug} 的晋升:当前已是 {lifecycle}", "loop_skip_illegal": "已跳过 {slug} 的非法迁移:无法从 {current} 转到 {target}", "loop_skip_unknown": "已跳过 {slug} 的晋升:未知同事", - "visible_on_ui_tenant": "资产已出现在当前登录租户的员工 / 人才 / 技能 / 插件列表中。", + "visible_on_ui_tenant": "资产已落到本组织的员工 / 人才 / 技能 / 插件列表。", "imported_skill": "已导入 openXYOS 技能 {slug}", "imported_plugin": "已导入 openXYOS 插件 {slug}", "imported_mcp": "已导入 openXYOS MCP {slug}" diff --git a/src/octop/modules/org_os/apply/apply.py b/src/octop/modules/org_os/apply/apply.py index 1f3fbe7d..e0a81552 100644 --- a/src/octop/modules/org_os/apply/apply.py +++ b/src/octop/modules/org_os/apply/apply.py @@ -60,6 +60,20 @@ def _items(doc: Any, key: str) -> list[dict[str, Any]]: return [] +def _ingest_tenant_id(workspace_tenant: str, client: OpenXyosControlClient) -> int | None: + """Map the org workspace tenant onto the control-plane ingest tenant. + + Numeric workspace tenants win. A sidecar session id is only a fallback when + the workspace tenant is not a positive integer (never a hardcoded ``1``). + """ + tid = (workspace_tenant or "").strip() + if tid.isdigit() and int(tid) > 0: + return int(tid) + if client.base_url: + return client.ui_tenant_id() + return None + + def apply_asset_pack( pack_dir: Path, *, @@ -184,17 +198,11 @@ def apply_asset_pack( control_plane_url=client.base_url, notes=[ "Local mirror is the durable record.", - "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", + "Assets land on this organization's Employees, Talent, Skills, and Plugins lists.", ], ) - ui_tenant = client.ui_tenant_id() if client.base_url else None - if ui_tenant is not None: - ingest_tenant: int | None = ui_tenant - elif tid.isdigit() and int(tid) > 0: - ingest_tenant = int(tid) - else: - ingest_tenant = None + ingest_tenant = _ingest_tenant_id(tid, client) result.tenant_id = ingest_tenant ingest_payload: dict[str, Any] = { @@ -284,7 +292,7 @@ def apply_asset_pack( f"control plane at {client.base_url} did not accept drafts; mirror is complete" ) elif not client.base_url: - result.notes.append("OPENXYOS_BASE_URL unset; applied to local mirror only") + result.notes.append("OPENXYOS_BASE_URL / runtime.json unset; applied to local mirror only") return result diff --git a/src/octop/modules/org_os/apply/client.py b/src/octop/modules/org_os/apply/client.py index 4702c387..f3f58949 100644 --- a/src/octop/modules/org_os/apply/client.py +++ b/src/octop/modules/org_os/apply/client.py @@ -22,13 +22,22 @@ DEFAULT_CONTROL_URL = "http://127.0.0.1:3780" -def resolve_control_plane_url(*candidates: str) -> str: - """Prefer ``OPENXYOS_BASE_URL``, then explicit args, then sidecar env.""" +def resolve_control_plane_url(*candidates: str, home: Path | None = None) -> str: + """Prefer ``OPENXYOS_BASE_URL``, then explicit args, sidecar env, runtime.json.""" ordered = [ os.environ.get("OPENXYOS_BASE_URL", "").strip(), *candidates, os.environ.get("FREEOS_ORG_SIDECAR_URL", "").strip(), ] + home_path = home + if home_path is None: + raw_home = (os.environ.get("FREEOS_HOME") or os.environ.get("OCTOP_HOME") or "").strip() + if raw_home: + home_path = Path(raw_home) + if home_path is not None: + from octop.modules.org_os.managed_runtime import read_runtime_base_url # noqa: PLC0415 + + ordered.append(read_runtime_base_url(home_path)) for raw in ordered: cleaned = (raw or "").strip().rstrip("/") parsed = urlparse(cleaned) @@ -75,10 +84,10 @@ def __init__( home: Path | None = None, retries: int = 4, ) -> None: - self.base_url = resolve_control_plane_url(base_url) self.timeout = timeout self.retries = max(retries, 1) self.home = Path(home) if home is not None else None + self.base_url = resolve_control_plane_url(base_url, home=self.home) self.headers = dict(headers or {}) token = self.headers.get("X-FreeOS-Ingest-Token") or resolve_ingest_token(self.home) env_token = (os.environ.get(INGEST_TOKEN_KEY) or "").strip() diff --git a/src/octop/modules/org_os/assets/pack.py b/src/octop/modules/org_os/assets/pack.py index a51fe725..c9d1bffb 100644 --- a/src/octop/modules/org_os/assets/pack.py +++ b/src/octop/modules/org_os/assets/pack.py @@ -243,7 +243,7 @@ def publish_asset_pack( mcp_count=mcp_count, agent_count=agent_count, notes=[ - "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", + "Assets land on this organization's Employees, Talent, Skills, and Plugins lists.", "One tenant = one workspace; do not unpack this pack into a shared sandbox.", "Agent .env values are redacted except tenant/slug/schema keys.", ], diff --git a/src/octop/modules/org_os/empower.py b/src/octop/modules/org_os/empower.py index 8a60725d..b27c0479 100644 --- a/src/octop/modules/org_os/empower.py +++ b/src/octop/modules/org_os/empower.py @@ -127,7 +127,7 @@ def pack_to_openxyos(service: OrgModuleService) -> dict[str, Any]: pack.directory, home=service.home, tenant_id=service.tenant_id() or "default", - base_url=service.sidecar_url() if sidecar.reachable else "", + base_url=service.explicit_sidecar_url() if sidecar.reachable else "", ) return {"pack": pack.to_dict(), "applied": applied.to_dict()} diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py index e511ca4c..8acd7bec 100644 --- a/src/octop/modules/org_os/loop/run.py +++ b/src/octop/modules/org_os/loop/run.py @@ -116,24 +116,26 @@ def _promote(store: LifecycleStore, slug: str) -> tuple[dict[str, str], list[str def run_growth_loop( home: Path, *, - tenant_id: str = "1", + tenant_id: str = "", blueprint_path: Path | None = None, policies_path: Path | None = None, sidecar_url: str = "", config_path: Path | None = None, owner_user_id: int | None = None, + organization_id: int | None = None, ) -> LoopProof: """Run the finished self-growth loop against *home*. - Uses bundled fixtures and the in-host org registry. When - ``OPENXYOS_BASE_URL`` (or an explicit *sidecar_url*) is set, outbound - packs are also POSTed to that optional control plane. The loop never + Uses bundled fixtures and the in-host org registry. When a managed + organization runtime, ``OPENXYOS_BASE_URL``, or ``org-os/runtime.json`` + (or an explicit *sidecar_url*) is present, outbound packs are POSTed to + that control plane for the current org workspace tenant. The loop never starts or waits for a local Node sidecar. """ home = Path(home) home.mkdir(parents=True, exist_ok=True) - tid = tenant_id or "1" service = OrgModuleService(config_path=config_path or (home / "config.json"), home=home) + tid = service.workspace_tenant_id(tenant_id, organization_id=organization_id) service.set_enabled(True, sidecar_url=sidecar_url or None) service.set_governance_enabled(True, tenant_id=tid) @@ -141,7 +143,7 @@ def run_growth_loop( policies = Path(policies_path) if policies_path else policies_fixture() if not blueprint.is_file(): raise FileNotFoundError(f"blueprint fixture missing: {blueprint}") - control_url = resolve_control_plane_url(sidecar_url) + control_url = resolve_control_plane_url(sidecar_url, service.explicit_sidecar_url(), home=home) imported = import_openxyos_assets( home, diff --git a/src/octop/modules/org_os/managed_runtime.py b/src/octop/modules/org_os/managed_runtime.py index b44a5d99..2f4e4cfe 100644 --- a/src/octop/modules/org_os/managed_runtime.py +++ b/src/octop/modules/org_os/managed_runtime.py @@ -1,15 +1,24 @@ -"""Owned Node business process: private port, restart, and host-bound shutdown.""" +"""Owned Node business process: private port, restart, and host-bound shutdown. + +The listening origin is written to ``{FREEOS_HOME}/org-os/runtime.json`` so CLI +and ``/api/org-module/*`` can ingest into the same control plane without +hard-coding ``127.0.0.1:3780`` or requiring the caller to export +``OPENXYOS_BASE_URL``. +""" from __future__ import annotations import asyncio import contextlib +import json import logging import os import shutil import socket import subprocess from pathlib import Path +from typing import Any +from urllib.parse import urlparse from octop.modules.org_os.sidecar_launch import ( find_sidecar_runtime, @@ -19,12 +28,103 @@ logger = logging.getLogger(__name__) +RUNTIME_JSON_NAME = "runtime.json" + + +def runtime_json_path(home: Path) -> Path: + return Path(home) / "org-os" / RUNTIME_JSON_NAME + + +def _pid_looks_dead(pid: int) -> bool: + if pid <= 0: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + except (PermissionError, OSError, ValueError): + return False + return False + + +def _clean_base_url(raw: str) -> str: + cleaned = (raw or "").strip().rstrip("/") + parsed = urlparse(cleaned) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + return "" + return cleaned + + +def read_runtime_base_url(home: Path) -> str: + """Return the managed control-plane origin, or empty when none is live.""" + path = runtime_json_path(home) + if not path.is_file(): + return "" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeError): + return "" + if not isinstance(raw, dict): + return "" + pid = raw.get("pid") + if isinstance(pid, int) and _pid_looks_dead(pid): + return "" + url = raw.get("base_url") + if isinstance(url, str): + return _clean_base_url(url) + return "" + + +def write_runtime_record( + home: Path, + *, + base_url: str, + port: int | None = None, + pid: int | None = None, + managed: bool = True, +) -> Path: + """Persist the live control-plane origin for host/CLI ingest.""" + cleaned = _clean_base_url(base_url) + if not cleaned: + raise ValueError("managed runtime URL is not an http(s) origin") + path = runtime_json_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + payload: dict[str, Any] = {"base_url": cleaned, "managed": bool(managed)} + if port is not None: + payload["port"] = int(port) + if pid is not None: + payload["pid"] = int(pid) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return path + + +def update_runtime_pid(home: Path, pid: int) -> None: + path = runtime_json_path(home) + data: dict[str, Any] = {} + if path.is_file(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeError): + raw = {} + if isinstance(raw, dict): + data = raw + data["pid"] = int(pid) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def clear_runtime_record(home: Path) -> None: + path = runtime_json_path(home) + with contextlib.suppress(OSError): + path.unlink() + class ManagedOrganizationRuntime: def __init__(self, home: Path) -> None: self.home = home self.process: asyncio.subprocess.Process | None = None self.task: asyncio.Task[None] | None = None + self.base_url: str = "" async def start(self) -> None: source = Path(__file__).resolve().parents[4] / "modules" / "openxyos" @@ -48,8 +148,15 @@ async def start(self) -> None: with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] + self.base_url = f"http://127.0.0.1:{port}" os.environ["FREEOS_ORG_SIDECAR_PORT"] = str(port) - os.environ["OPENXYOS_BASE_URL"] = f"http://127.0.0.1:{port}" + os.environ["OPENXYOS_BASE_URL"] = self.base_url + await asyncio.to_thread( + write_runtime_record, + self.home, + base_url=self.base_url, + port=port, + ) env = await asyncio.to_thread(sidecar_launch_env, self.home) env.update( { @@ -73,6 +180,10 @@ async def supervise() -> None: stderr=log, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, ) + if self.process.pid: + await asyncio.to_thread( + update_runtime_pid, self.home, int(self.process.pid) + ) result = await self.process.wait() logger.error("Organization runtime exited (%s); restarting in %ss", result, delay) await asyncio.sleep(delay) @@ -92,3 +203,5 @@ async def stop(self) -> None: except TimeoutError: self.process.kill() await self.process.wait() + await asyncio.to_thread(clear_runtime_record, self.home) + self.base_url = "" diff --git a/src/octop/modules/org_os/notes.py b/src/octop/modules/org_os/notes.py index 10667958..da2383bf 100644 --- a/src/octop/modules/org_os/notes.py +++ b/src/octop/modules/org_os/notes.py @@ -117,7 +117,7 @@ (), ), ( - re.compile(r"^OPENXYOS_BASE_URL unset; applied to local mirror only$"), + re.compile(r"^OPENXYOS_BASE_URL / runtime.json unset; applied to local mirror only$"), "org.notes.mirror_only", (), ), @@ -229,7 +229,7 @@ ), ( re.compile( - r"^Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists\.$" + r"^Assets land on this organization's Employees, Talent, Skills, and Plugins lists\.$" ), "org.notes.visible_on_ui_tenant", (), diff --git a/src/octop/modules/org_os/service.py b/src/octop/modules/org_os/service.py index 1f666987..e105b886 100644 --- a/src/octop/modules/org_os/service.py +++ b/src/octop/modules/org_os/service.py @@ -115,11 +115,17 @@ def explicit_sidecar_url(self) -> str: The implicit ``http://127.0.0.1:3780`` default is display-only. Growth loop / apply must not treat that fallback as a required control plane. + A live managed runtime (``org-os/runtime.json``) is an explicit origin. """ for key in ("OPENXYOS_BASE_URL", "FREEOS_ORG_SIDECAR_URL"): env = os.environ.get(key, "").strip() if env: return normalize_sidecar_url(env) + from octop.modules.org_os.managed_runtime import read_runtime_base_url # noqa: PLC0415 + + recorded = read_runtime_base_url(self.home) + if recorded: + return normalize_sidecar_url(recorded) section = self._section() raw = section.get("sidecar_url") if isinstance(raw, str) and raw.strip(): @@ -137,6 +143,22 @@ def tenant_id(self) -> str: raw = section.get("tenant_id") return str(raw).strip() if raw is not None and str(raw).strip() else "" + def workspace_tenant_id(self, explicit: str = "", *, organization_id: int | None = None) -> str: + """Org workspace tenant for ingest/mirror — never a hardcoded ``1``. + + Precedence: caller argument, ``FREEOS_ORG_TENANT_ID`` / config, then the + signed-in organization room id. FreeOS studio identity stays separate. + """ + cleaned = (explicit or "").strip() + if cleaned: + return cleaned + tid = self.tenant_id() + if tid: + return tid + if organization_id is not None and int(organization_id) > 0: + return str(int(organization_id)) + return "default" + def governance_enabled(self) -> bool: section = self._section() gov = section.get("governance") diff --git a/tests/unit/test_openxyos_apply.py b/tests/unit/test_openxyos_apply.py index 2acb8f2e..f6dcef5d 100644 --- a/tests/unit/test_openxyos_apply.py +++ b/tests/unit/test_openxyos_apply.py @@ -5,6 +5,8 @@ import json from pathlib import Path +import pytest + from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces from octop.modules.org_os.assets.importer import import_openxyos_assets from octop.modules.org_os.assets.pack import publish_asset_pack @@ -13,7 +15,7 @@ _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "org-loop" / "agent-blueprint.v1.json" -def test_apply_without_url_is_mirror_only(tmp_path: Path, monkeypatch) -> None: +def test_apply_without_url_is_mirror_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) import_openxyos_assets( @@ -90,7 +92,7 @@ def test_apply_uses_ingest_token(tmp_path: Path) -> None: server.server_close() -def test_apply_sends_ui_tenant_not_configured_tenant(tmp_path: Path) -> None: +def test_apply_ingests_org_workspace_tenant_not_sidecar_session(tmp_path: Path) -> None: import_openxyos_assets( tmp_path, tenant_id="1", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False ) @@ -101,9 +103,9 @@ def test_apply_sends_ui_tenant_not_configured_tenant(tmp_path: Path) -> None: try: result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="1", base_url=url) assert result.remote_applied is True - assert result.tenant_id == 2 - assert state.ingested.get("tenant_id") == 2 - assert result.landed.get("tenant_id") == 2 + assert result.tenant_id == 1 + assert state.ingested.get("tenant_id") == 1 + assert result.landed.get("tenant_id") == 1 assert result.preview_path == "/employees" assert result.landed.get("landed", {}).get("employees", {}).get("created", 0) >= 1 employees = state.ingested.get("employees") or [] @@ -118,6 +120,32 @@ def test_apply_sends_ui_tenant_not_configured_tenant(tmp_path: Path) -> None: server.server_close() +def test_apply_ingests_via_runtime_json_without_env_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os.managed_runtime import write_runtime_record + + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + import_openxyos_assets( + tmp_path, tenant_id="42", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False + ) + pack = publish_asset_pack(tmp_path, tenant_id="42", out_dir=tmp_path / "asset-packs" / "latest") + state = ControlPlaneState() + state.ui_tenant_id = 9 + url, server = start_control_plane(state) + try: + write_runtime_record(tmp_path, base_url=url) + result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="42", base_url="") + assert result.remote_applied is True + assert result.control_plane_url == url + assert state.ingested.get("tenant_id") == 42 + assert "/api/freeos/ingest" in state.posts + finally: + server.shutdown() + server.server_close() + + def test_import_sidecar_assets_are_selectable_on_freeos(tmp_path: Path) -> None: state = ControlPlaneState() state.employees = [ diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py index f09d8189..a8a54c00 100644 --- a/tests/unit/test_org_loop.py +++ b/tests/unit/test_org_loop.py @@ -161,6 +161,41 @@ def test_growth_loop_applies_to_live_control_plane(tmp_path: Path) -> None: assert "/api/freeos/ingest" in state.posts assert "employees" in proof.imported_roundtrip["remote_surfaces"] assert proof.landed + spawned = list_spawned_agents(tmp_path) + assert {item.slug for item in spawned} >= {"policy-analyst", "ops-coordinator"} + assert all(item.agent_id.startswith("org-") for item in spawned) + finally: + server.shutdown() + server.server_close() + + +def test_growth_loop_ingests_when_runtime_json_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os.managed_runtime import write_runtime_record + from octop.modules.org_os.service import OrgModuleService + + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR", raising=False) + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + service.set_enabled(True) + service.set_governance_enabled(True, tenant_id="42") + state = ControlPlaneState() + state.ui_tenant_id = 9 + url, server = start_control_plane(state) + try: + write_runtime_record(tmp_path, base_url=url) + proof = run_growth_loop(tmp_path, config_path=tmp_path / "config.json") + assert proof.ok is True + assert proof.remote_applied is True + assert proof.tenant_id == "42" + assert state.ingested.get("tenant_id") == 42 + assert "/api/freeos/ingest" in state.posts + spawned = list_spawned_agents(tmp_path) + assert spawned + assert all(item.agent_id.startswith("org-") for item in spawned) + assert (tmp_path / "org-agents" / "registry.json").is_file() finally: server.shutdown() server.server_close() diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py index 95721011..16c7ce2f 100644 --- a/tests/unit/test_org_module.py +++ b/tests/unit/test_org_module.py @@ -3,6 +3,9 @@ from __future__ import annotations import json +import os +import subprocess +import sys from pathlib import Path import httpx @@ -14,6 +17,11 @@ default_openxyos_catalog_path, load_upstream_catalog_keys, ) +from octop.modules.org_os.managed_runtime import ( + clear_runtime_record, + read_runtime_base_url, + write_runtime_record, +) from octop.modules.org_os.proxy import identity_headers, sidecar_target from octop.modules.org_os.service import ( DEFAULT_SIDECAR_URL, @@ -21,6 +29,8 @@ OrgModuleService, ) +posix_only = pytest.mark.skipif(os.name != "posix", reason="pid liveness uses os.kill") + def test_catalog_matches_vendored_openxyos() -> None: upstream = default_openxyos_catalog_path() @@ -276,6 +286,41 @@ def _boom(*args: object, **kwargs: object) -> None: assert health.reachable is False assert health.detail == "sidecar optional; not configured" assert service.explicit_sidecar_url() == "" + assert service.workspace_tenant_id() == "default" + assert service.workspace_tenant_id(organization_id=7) == "7" + + +def test_explicit_sidecar_url_reads_runtime_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR", raising=False) + write_runtime_record(tmp_path, base_url="http://127.0.0.1:4099", port=4099) + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + assert service.explicit_sidecar_url() == "http://127.0.0.1:4099" + assert read_runtime_base_url(tmp_path) == "http://127.0.0.1:4099" + clear_runtime_record(tmp_path) + assert service.explicit_sidecar_url() == "" + + +def test_workspace_tenant_prefers_config_over_organization_id(tmp_path: Path) -> None: + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + service.set_governance_enabled(True, tenant_id="42") + assert service.workspace_tenant_id("", organization_id=9) == "42" + assert service.workspace_tenant_id("acme", organization_id=9) == "acme" + + +@posix_only +def test_runtime_json_ignores_dead_pid(tmp_path: Path) -> None: + proc = subprocess.Popen( # noqa: S603 + [sys.executable, "-c", "import time; time.sleep(30)"], + ) + pid = int(proc.pid) + proc.kill() + proc.wait() + write_runtime_record(tmp_path, base_url="http://127.0.0.1:4099", pid=pid) + assert read_runtime_base_url(tmp_path) == "" def test_overview_lists_in_host_colleagues_after_assemble(tmp_path: Path) -> None: diff --git a/tests/unit/test_org_notes.py b/tests/unit/test_org_notes.py index c7cb4892..88621430 100644 --- a/tests/unit/test_org_notes.py +++ b/tests/unit/test_org_notes.py @@ -12,7 +12,7 @@ def test_localize_loop_notes_zh() -> None: "Loop produced colleagues, published an asset pack, applied department employees, and imported back.", "skipped promotion for ops-coordinator: already active", "skipped illegal transition for policy-analyst: cannot move active → market", - "Assets are visible on the logged-in tenant's Employees, Talent, Skills, and Plugins lists.", + "Assets land on this organization's Employees, Talent, Skills, and Plugins lists.", "imported openXYOS skill org-governance", ], "zh", @@ -25,7 +25,7 @@ def test_localize_loop_notes_zh() -> None: assert "循环已生成同事" in notes[5] assert "ops-coordinator" in notes[6] and "active" in notes[6] assert "policy-analyst" in notes[7] and "market" in notes[7] - assert "当前登录租户" in notes[8] + assert "本组织" in notes[8] assert "org-governance" in notes[9]