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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>` 聊天智能体。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 <dir>` 从 `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`。
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <id>` and the agent re-checks with the same args.

Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
"assembleNext": "下一步:打开「对话」建群,把同事拉进来。还没入编的帮手在「专家」页。",
"assemblePreviewExperts": "正在打开「专家」页,导入的同事可在那里选用。",
"packReceipt": "已打包并推送到当前登录的 openXYOS 租户。",
"packLanded": "当前登录租户 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}",
"packLanded": "本组织工作区 {{tenant}}:员工 {{employees}},人才 {{talent}},技能 {{skills}},插件 {{plugins}}",
"packPreviewEmployees": "右侧预览已打开员工页,可直接核对。",
"navWorkbench": "工作台",
"navWorkspace": "总览",
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/Organization/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
});

Expand Down
13 changes: 11 additions & 2 deletions docs/asset-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ That path:
- compiles colleagues and promotes `draft → market → recruit → shadow → active`
- **registers real FreeOS agents** (`org-<slug>`) 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`)

Expand Down Expand Up @@ -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/<tenant>/`. 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/<tenant>/`. 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-<slug>` agents |
| No runtime | Same loop; durable `openxyos-mirror/` only; `remote_applied=false` |

## Where files live

Expand All @@ -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/<tenant>/` |
| Managed runtime origin | `{FREEOS_HOME}/org-os/runtime.json` |
| Loop proof | `{FREEOS_HOME}/asset-packs/loop-proof.json` |

## High-risk actions
Expand Down
26 changes: 19 additions & 7 deletions src/octop/api/routers/org_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 = ""
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions src/octop/cli/commands/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))

Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions src/octop/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}"
Expand Down
4 changes: 2 additions & 2 deletions src/octop/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "边车导出不可达;回退到目录/蓝图",
Expand All @@ -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}"
Expand Down
26 changes: 17 additions & 9 deletions src/octop/modules/org_os/apply/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -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


Expand Down
15 changes: 12 additions & 3 deletions src/octop/modules/org_os/apply/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading