diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc402e1..67228754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ ### 新增 +- Finished self-growth loop: `freeos org loop run` compiles blueprints, promotes colleagues, registers FreeOS chat agents, publishes an asset pack, applies it to openXYOS (HTTP + local mirror), imports back, and proves governance blocks high-risk tools +- `freeos org assets apply` POSTs/PUTs employees, talent, plugins, and module-settings; durable `{FREEOS_HOME}/openxyos-mirror/` +- Inbound import / lifecycle `shadow|active` spawn real FreeOS agents (`org-`) via the host agents table + routing +- Host-wide governance: `OrgGovernanceMiddleware` spliced into the harness tool path so pending/deny cannot execute +- Colleague routing + org-knowledge live memory so produced employees are usable in FreeOS chat +- Demo fixtures in `tests/fixtures/org-loop/` and e2e proof `tests/e2e/test_org_growth_loop.py` - Phase A: `xyos-governance-mcp` default-denies high-risk tools and durable-pauses for human approval (`freeos org governance`) - Phase A: module ↔ skill bridge generates catalog skills with tenant headers and a tenant-toggle publish draft (`freeos org skills`) - Phase B: `xyos2freeos` compiles `openxyos.agent-blueprint.v1` into tenant-scoped employee workspaces diff --git a/README.md b/README.md index 98983e05..b54814e6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- FreeOS — a self-hosted multi-user, multi-agent assistant with an optional organization OS. + FreeOS — a self-hosted multi-agent OS that grows its own AI workforce.

@@ -12,16 +12,44 @@

+ The loop · Run · - Organization module · License · Architecture

-**FreeOS** is an independent open-source host for teams and individuals. The control plane is derived from [Octop](https://github.com/TencentCloud/Octop) (MIT). Organization capabilities come from [openXYOS](https://github.com/XYAIStudio/openXYOS) (Apache-2.0), wired as an **enable/disable sidecar module** — not a trademark or product affiliation with Tencent Cloud or XYAIStudio. +**FreeOS** is an independent open-source host. The data plane is derived from [Octop](https://github.com/TencentCloud/Octop) (MIT). The organization control plane comes from [openXYOS](https://github.com/XYAIStudio/openXYOS) (Apache-2.0). They are complementary: FreeOS **produces** AI employees, skills, plugins, and MCPs; those assets **assemble into openXYOS**; openXYOS blueprints, catalog, policies, and talent **feed back** into FreeOS to spawn or upgrade more runtime agents. The web shell, README banner, favicons, and PWA icons use the FreeOS circular mark (gray ring, yellow / green / red teardrops, blue center). +## Run the self-growth loop + +This is the product path. One command compiles a blueprint into an AI employee, promotes it `draft → market → recruit → shadow → active`, registers a FreeOS chat agent, publishes an asset pack, applies that pack onto openXYOS-shaped surfaces (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. + +```bash +git clone https://github.com/XYAIStudio/FreeOS.git +cd FreeOS +uv sync +uv run freeos org loop run +``` + +Equivalent: `bash scripts/org-loop.sh`. + +CI / scripted proof of the same loop: + +```bash +uv run pytest tests/e2e/test_org_growth_loop.py tests/unit/test_org_loop.py tests/unit/test_openxyos_apply.py tests/unit/test_colleague_spawn.py tests/unit/test_host_governance.py +``` + +| 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/PUT `/api/employees`, `/api/talent`, `/api/plugins`, `/api/module-settings` | + +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. + +Operator detail: [docs/asset-loop.md](docs/asset-loop.md). + ## Run FreeOS ### Prerequisites @@ -32,16 +60,13 @@ The web shell, README banner, favicons, and PWA icons use the FreeOS circular ma ### From this repository ```bash -git clone https://github.com/XYAIStudio/FreeOS.git -cd FreeOS -uv sync uv run freeos init uv run freeos run ``` -`freeos` is an alias of the upstream `octop` CLI (same package). Either name works. +`freeos` is the product CLI. `octop` remains a package-compatible alias. -Open the dashboard (default port is the Octop/FreeOS listen port printed by `run`, commonly `http://127.0.0.1:18900`). Complete the first-run wizard. +Open the dashboard (default listen port is printed by `run`, commonly `http://127.0.0.1:18900`). Complete the first-run wizard. Data directory precedence: @@ -64,10 +89,10 @@ The Vite app in `dashboard/` is what you edit; packaged builds land in `src/octo ### Tests ```bash -uv run pytest tests/unit/test_org_module.py tests/unit/test_paths.py tests/unit/test_bundled_plugins_layout.py tests/unit/test_governance_mcp.py tests/unit/test_skill_bridge.py tests/unit/test_blueprint_compiler.py tests/unit/test_lifecycle.py tests/unit/test_asset_loop.py +uv run pytest tests/e2e/test_org_growth_loop.py tests/unit/test_org_loop.py tests/unit/test_org_module.py tests/unit/test_governance_mcp.py tests/unit/test_skill_bridge.py tests/unit/test_blueprint_compiler.py tests/unit/test_lifecycle.py tests/unit/test_asset_loop.py tests/unit/test_openxyos_apply.py tests/unit/test_colleague_spawn.py tests/unit/test_host_governance.py ``` -Full Octop-derived suite: `uv run pytest` / `make test-fast` (needs the usual extra services for marked tests). +Full host suite: `uv run pytest` / `make test-fast` (needs the usual extra services for marked tests). ## Enable the organization module @@ -86,56 +111,25 @@ Then start the sidecar (Node 20+): bash scripts/run-org-sidecar.sh ``` -Default origin: `http://127.0.0.1:3780` (`FREEOS_ORG_SIDECAR_URL` / `FREEOS_ORG_SIDECAR_PORT` to override). When `/api/health/livez` succeeds, `/organization` embeds the org console and `/api/org-module/sidecar/*` proxies to it. - -Sidecar env is generated at `modules/openxyos/.env` on first launch (do not commit secrets). See `modules/openxyos/.env.example` and [docs/architecture-integration.md](docs/architecture-integration.md). - -**Auth (MVP):** FreeOS JWT users and openXYOS tenants are separate. The proxy forwards `X-FreeOS-User*` and `X-FreeOS-Tenant-Id`; sign in to the sidecar for mutating org APIs. One tenant = one workspace/sandbox — not prompt isolation. - -## Phase A — governance MCP + module skills +Default origin: `http://127.0.0.1:3780` (`FREEOS_ORG_SIDECAR_URL` / `OPENXYOS_BASE_URL` / `FREEOS_ORG_SIDECAR_PORT` to override). When `/api/health/livez` succeeds, `/organization` embeds the org console and `/api/org-module/sidecar/*` proxies to it. -openXYOS is the **control plane**. FreeOS/Octop stays the **data plane** (do not replace Octop chat). Phase A inserts governance before high-risk tools and generates skills from the module catalog. +**Auth:** FreeOS JWT users and openXYOS tenants are separate. The proxy forwards `X-FreeOS-User*` and `X-FreeOS-Tenant-Id`. One tenant = one workspace/sandbox — not prompt isolation. -### Direction 2 — `xyos-governance-mcp` +### Manual loop steps (the one command above already does this) ```bash -uv run freeos org governance enable -# writes {FREEOS_HOME}/governance/xyos-governance-mcp.json — add that stdio server to the agent -uv run xyos-governance-mcp # optional: run by hand -uv run freeos org governance check --tool delete_employee --category delete -``` - -High-risk classes (`outbound`, `delete`, `pay`, `prod`) **default-deny**. Human approval is a durable pause under `~/.freeos/governance/pauses/`. `execute` stays false until `freeos org governance approve ` (or IM `/approve` once wired) and the agent **re-checks** with the same args. How-to: [src/octop/modules/org_os/governance/README.md](src/octop/modules/org_os/governance/README.md). - -### Direction 3 — module ↔ skill bridge - -```bash -uv run freeos org skills generate -uv run freeos org skills publish ~/.freeos/org-skills/org-employees -``` - -Each generated `SKILL.md` calls real `/api/…` routes through `/api/org-module/sidecar` with tenant headers. Publish writes a **disabled** tenant-toggleable plugin draft — it does not auto-enable. How-to: [src/octop/modules/org_os/skill_bridge/README.md](src/octop/modules/org_os/skill_bridge/README.md). - -### Phase B — grow the AI workforce (blueprints, lifecycle, asset loop) - -Non-developer loop (details in [docs/asset-loop.md](docs/asset-loop.md)): - -```bash -uv run freeos org assets import --catalog --blueprint tests/fixtures/agent-blueprint.v1.json --tenant-id 1 +uv run freeos org enable +uv run freeos org governance enable --tenant-id 1 +uv run freeos org assets import --catalog --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1 uv run freeos org employee transition policy-analyst market uv run freeos org employee transition policy-analyst recruit uv run freeos org employee transition policy-analyst shadow uv run freeos org employee transition policy-analyst active uv run freeos org assets publish -uv run freeos org employee export-profile policy-analyst --tenant-id 1 +uv run freeos org assets apply ``` -- **Inbound:** catalog / blueprint / policies → skills + employee workspace (`xyos2freeos`) -- **Lifecycle:** `draft → market → recruit → shadow → active → offboard` (offboard revokes `.env` and archives MEMORY) -- **Outbound:** asset pack + openXYOS-shaped drafts (not auto-enabled) -- High-risk tools still go through Phase A governance - -Phase C (chat routing, reflections, org-as-code) stays architecture-only. +Inbound import compiles the blueprint **and** registers a FreeOS agent (`org-policy-analyst`) so the colleague is addressable in chat. Governance is spliced into the host tool path — a pending/deny decision is a hard stop. ## Host platform features (from Octop) @@ -153,12 +147,11 @@ Longer host docs: [docs/user-guide.md](docs/user-guide.md), [docs/configuration. ## Remaining `octop` identifiers -Intentional for MVP compatibility (not a rebrand miss): +Intentional for package compatibility (not a rebrand miss): - Python package name `octop` and `import octop` - CLI `octop` alongside `freeos` - `OCTOP_*` environment variables - SQLite file `octop.db` inside the home directory -- Many internal tests and IM strings -See [docs/architecture-integration.md](docs/architecture-integration.md) for the seven integration directions, Phase A/B/C, and the chosen MVP topology. +See [docs/architecture-integration.md](docs/architecture-integration.md) for the control/data-plane split and the running self-growth loop. diff --git a/README_CN.md b/README_CN.md index f5a98016..5607704a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -3,10 +3,10 @@

- FreeOS — 自托管多用户、多 Agent 助手,可选组织 OS 模块。 + FreeOS — 自托管多智能体 OS,能生产并回收自己的 AI 员工。

-

运行、组织模块、治理 MCP、蓝图编译与资产环见 README.md;七条集成方向见 docs/architecture-integration.md;非开发者闭环见 docs/asset-loop.md。

+

产品闭环:uv run freeos org loop run。详见 README.md 与 docs/asset-loop.md。

Python 3.12+ diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 335a2513..6a6902ce 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -8,13 +8,13 @@ }, "organization": { "title": "Organization OS", - "subtitle": "Enable the openXYOS organization module as a FreeOS sidecar.", + "subtitle": "Grow AI employees through the FreeOS ↔ openXYOS loop. Enable the organization control plane as a sidecar.", "toggleLabel": "Enable organization module", "on": "Enabled", "off": "Disabled", "sidecarUp": "Sidecar reachable", "sidecarDown": "Sidecar offline", - "bridgeHint": "This is a modular bridge: FreeOS keeps the host identity and plugins; openXYOS serves org APIs and UI on a sidecar.", + "bridgeHint": "FreeOS produces employees, skills, plugins, and MCPs. Those assets assemble into openXYOS; org blueprints and policies feed back into FreeOS. Run `freeos org loop run` to prove the loop.", "home": "Data home", "sidecar": "Sidecar URL", "startSidecarTitle": "Start the organization sidecar", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index c8c2ab6e..92f28857 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -8,13 +8,13 @@ }, "organization": { "title": "组织 OS", - "subtitle": "将 openXYOS 组织能力作为 FreeOS 模块启用或关闭。", + "subtitle": "通过 FreeOS ↔ openXYOS 闭环生产 AI 员工。将组织控制面作为边车启用。", "toggleLabel": "启用组织模块", "on": "已启用", "off": "已关闭", "sidecarUp": "边车可达", "sidecarDown": "边车未运行", - "bridgeHint": "这是模块化桥接:FreeOS 保留宿主身份与插件;openXYOS 在边车中提供组织 API 与界面。", + "bridgeHint": "FreeOS 生产员工、技能、插件与 MCP;这些资产组装进 openXYOS,组织蓝图与策略再回流到 FreeOS。用 `freeos org loop run` 跑通闭环。", "home": "数据目录", "sidecar": "边车地址", "startSidecarTitle": "启动组织边车", diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index dc16ee69..57f51c27 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -20,10 +20,8 @@ FreeOS grows its own AI workforce and capability catalog: 3. openXYOS assets (org tree, `openxyos.agent-blueprint.v1`, governance, module catalog, talent market, knowledge/reflections) **feed back** into FreeOS to spawn or upgrade more runtime agents and skills. -4. The loop repeats. Phase A is the safety gate + module↔skill pipe. - Phase B is the blueprint compiler, colleague lifecycle, and the - bidirectional asset factory. Operator guide: - [asset-loop.md](asset-loop.md). +4. The loop repeats and is **runnable**: `freeos org loop run`. Operator + guide: [asset-loop.md](asset-loop.md). FreeOS is an independent project. It is not affiliated with Tencent Cloud, Octop, XYAIStudio, or XYOS trademarks beyond accurate license attribution. @@ -46,11 +44,12 @@ See [NOTICE](../NOTICE) and `modules/openxyos/TRADEMARKS.md`. talent market running agent instances ``` -## Chosen MVP topology (Phase A — Directions 2 + 3) +## Running topology -Phase A does **not** compile blueprints or merge chat UIs. It inserts -governance in front of high-risk data-plane tools, and generates real -module skills that call sidecar `/api/*` with tenant headers. +The host inserts governance in front of high-risk data-plane tools, +generates module skills that call sidecar `/api/*` with tenant headers, +compiles blueprints into chat-usable colleagues, and applies asset packs +onto openXYOS-shaped surfaces. ``` ┌─────────────────────────────────────────────┐ @@ -127,11 +126,9 @@ Polished → freeos org skills publish

→ operator enables per tenant; not auto-on ``` -## Seven directions +## Seven directions (all required for the running loop) -### Phase A — implement now (highest value / verifiable) - -#### Direction 2 — `xyos-governance-mcp` +#### Direction 2 — `xyos-governance-mcp` + host tool_guard Wrap openXYOS governance/audit as an MCP server and insert it **before** high-risk tool calls. @@ -163,7 +160,7 @@ until the pause is approved for the same action digest. | CLI | `freeos org skills generate` · `freeos org skills publish` | | How-to | `src/octop/modules/org_os/skill_bridge/README.md` | -### Phase B — implemented (Directions 1 + 4 + asset loop) +### Directions 1 + 4 + asset loop (implemented) #### Direction 1 — `xyos2freeos` / blueprint compiler @@ -214,25 +211,25 @@ sidecar SQL.js. High-risk runtime actions still pass through Phase A governance. -### Phase C — architecture only (do not implement in this pass) - -#### Direction 5 — Chat / session routing Web ↔ IM +#### Direction 5 — Chat / session routing (implemented as far as the loop needs) -One conversation identity across FreeOS dashboard chat and Octop IM -channels. Route; do not duplicate openXYOS chat as the agent loop. +Compiled colleagues are registered as FreeOS agents (`org-`) with a +tenant routing table (`tenants//routing.json`). `AgentManager.resolve_user_agent` +resolves colleague slugs and display names so produced employees are +usable in FreeOS chat. This does **not** duplicate openXYOS chat. -#### Direction 6 — Reflections ↔ harness-memory +#### Direction 6 — Org memory hooks (implemented as far as the loop needs) -openXYOS reflections become an org learning loop that writes into -harness-memory (and back). Keep tenant isolation at the workspace -boundary, not in the prompt. +Colleague `MEMORY.md` + `knowledge/` sync into +`tenants//org-knowledge/live//`. Offboard still archives into +`org-knowledge/archived-colleagues/`. Tenant isolation stays at the +workspace boundary. -#### Direction 7 — Org-as-code GitOps (end-state, not MVP start) +#### Direction 7 — Org-as-code GitOps (beyond the running loop) -Reconcile control-plane YAML (modules, blueprints, policy) with running -agents. Desired end-state: git is the source of org intent; FreeOS -instances converge. Not the starting point — the contract and runtime -must exist first (Phases A/B). +Reconcile control-plane YAML with running agents. The contract and +runtime now exist; GitOps convergence remains an operator workflow on +top of `freeos org loop run`, not a substitute for it. ## Risks (encoded in design) @@ -277,7 +274,9 @@ The Phase A work sits on the existing FreeOS bootstrap: | Module ↔ skill bridge (Phase A) | Bridge | `freeos org skills generate|publish` | | Blueprint compiler (Phase B) | Bridge | `xyos2freeos` · `/api/org-module/blueprints/compile` | | Colleague lifecycle (Phase B) | Bridge | `freeos org employee *` · `{home}/tenants//employees/` | -| Asset factory (Phase B) | Bridge | `freeos org assets publish\|import` · `{home}/asset-packs/` | +| Asset factory | Bridge | `freeos org assets publish\|import\|apply` · `{home}/asset-packs/` · `{home}/openxyos-mirror/` | +| Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | +| Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | openXYOS sidecar | `/api/org`, `/api/employees`, `/api/governance`, `/api/module-settings`, … | | Org UI | openXYOS Vite/Express | sidecar `:3780`; iframe on `/organization` when healthy | @@ -348,6 +347,10 @@ copy the openXYOS working directory themselves. | Blueprint compiler | `src/octop/modules/org_os/compiler/` | | Colleague lifecycle | `src/octop/modules/org_os/lifecycle/` | | Asset factory | `src/octop/modules/org_os/assets/` | +| Apply + mirror | `src/octop/modules/org_os/apply/` | +| Loop orchestrator | `src/octop/modules/org_os/loop/` | +| Colleague spawn / routing / memory | `src/octop/modules/org_os/runtime/` | +| Host governance middleware | `src/octop/infra/agents/middleware/org_governance.py` | | Bundled plugin | `src/octop/infra/agents/plugins/bundled/org-os/` | | Dashboard page | `dashboard/src/pages/Organization/index.tsx` | | CLI | `src/octop/cli/commands/org.py` | diff --git a/docs/asset-loop.md b/docs/asset-loop.md index 5881eea7..4ec967c1 100644 --- a/docs/asset-loop.md +++ b/docs/asset-loop.md @@ -1,55 +1,62 @@ -# FreeOS self-growth loop (operator guide) +# FreeOS self-growth loop -FreeOS is meant to **grow its own AI workforce** over time. You do not -need to write code. The loop has four moves: +FreeOS **grows its own AI workforce**. The loop is the product: -1. **Produce** runtime assets on FreeOS/Octop (the data plane): AI - employees, skills, plugins, MCP connectors. -2. **Assemble** those assets into openXYOS (the control plane) so org - modules get stronger. -3. **Feed back** openXYOS org structure, blueprints, governance, the - module catalog, and the talent market into FreeOS to spawn or upgrade - more agents and skills. -4. **Repeat.** Each cycle adds colleagues and capabilities. +1. **Produce** runtime assets on FreeOS (the data plane): AI employees, skills, plugins, MCP connectors. +2. **Assemble** those assets into openXYOS (the control plane) so org modules get stronger. +3. **Feed back** openXYOS org structure, blueprints, governance, the module catalog, and the talent market into FreeOS to spawn or upgrade more agents and skills. +4. **Repeat.** Each cycle adds colleagues and capabilities that are usable in FreeOS chat and governed. -openXYOS does **not** replace Octop chat. Octop still runs the agents. -Governance from Phase A still **blocks** high-risk tools until a human -approves. +openXYOS does **not** replace FreeOS chat. FreeOS still runs the agents. Governance **blocks** high-risk tools until a human approves. -## One-time enable +## One command ```bash uv sync +uv run freeos org loop run +# or: bash scripts/org-loop.sh +``` + +That path: + +- enables the org module + governance gate +- imports the demo catalog / blueprint / policies (`tests/fixtures/org-loop/`) +- 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-shaped surfaces (`/api/employees`, `/api/talent`, `/api/plugins`, `/api/module-settings`) — live HTTP when `OPENXYOS_BASE_URL` is set, always a durable `{FREEOS_HOME}/openxyos-mirror/` +- imports the applied surfaces back +- proves a high-risk tool is blocked (`execute=false`) + +Proof lands at `{FREEOS_HOME}/asset-packs/loop-proof.json`. + +## One-time enable (optional, already done by `loop run`) + +```bash uv run freeos init uv run freeos org enable -bash scripts/run-org-sidecar.sh # optional control-plane UI +bash scripts/run-org-sidecar.sh # optional live control-plane UI uv run freeos org governance enable --tenant-id 1 ``` -Set a tenant and keep it. **One tenant = one workspace/sandbox.** Do not -share a home directory across companies. +Set a tenant and keep it. **One tenant = one workspace/sandbox.** Do not share a home directory across companies. -## The loop, in commands +## Manual steps (same loop, split out) ### Inbound — control plane → runtime -Import the module catalog (makes `org-*` skills) and/or a blueprint -(makes an AI employee workspace): - ```bash uv run freeos org assets import --catalog -uv run freeos org assets import --blueprint path/to/blueprint.json --tenant-id 1 -uv run freeos org assets import --policies path/to/matrix.json --tenant-id 1 -# optional: GET sidecar /api/governance/permissions (best-effort) +uv run freeos org assets import --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1 +uv run freeos org assets import --policies tests/fixtures/org-loop/policies.json --tenant-id 1 uv run freeos org assets import --from-sidecar --tenant-id 1 # same as: -uv run xyos2freeos path/to/blueprint.json --tenant-id 1 -uv run freeos org employee spawn --blueprint path/to/blueprint.json --tenant-id 1 +uv run xyos2freeos tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1 +uv run freeos org employee spawn --blueprint tests/fixtures/org-loop/agent-blueprint.v1.json --tenant-id 1 ``` -A blueprint is JSON with `"schema": "openxyos.agent-blueprint.v1"` -(name, positioning, capabilities, optional experience / references / -`job_duties` for cron). +A blueprint is JSON with `"schema": "openxyos.agent-blueprint.v1"`. +Import **spawns a FreeOS agent** (workspace + `octop.db` row + routing table), not only files on disk. ### Lifecycle — grow a colleague safely @@ -61,70 +68,40 @@ draft → market → recruit → shadow → active → offboard uv run freeos org employee list --tenant-id 1 uv run freeos org employee transition my-analyst market uv run freeos org employee transition my-analyst recruit -uv run freeos org employee transition my-analyst shadow # read-only + human review +uv run freeos org employee transition my-analyst shadow # read-only + human review; agent registered uv run freeos org employee transition my-analyst active # cron from job duties turns on uv run freeos org employee transition my-analyst offboard # revoke .env + archive MEMORY ``` -| FreeOS state | Meaning | openXYOS mapping | -|---|---|---| -| `draft` | Workspace compiled, not listed | not in talent market | -| `market` | Listed for hire | `talent_pool.status = available` | -| `recruit` | Hired into reserve | `recruited` + `employment_category = reserve` | -| `shadow` | Probation: read-only, governance on | `probation` | -| `active` | Full runtime colleague | `staff` | -| `offboard` | Credentials cleared, memory archived | `archived` / `inactive` | +Shadow and offboard keep cron **disabled**. Offboard copies `MEMORY.md` into `{FREEOS_HOME}/tenants//org-knowledge/archived-colleagues/`. Live colleagues sync MEMORY + knowledge into `org-knowledge/live//` so chat has org memory. -Shadow and offboard keep cron **disabled**. Offboard copies `MEMORY.md` -into `{FREEOS_HOME}/tenants//org-knowledge/archived-colleagues/`. - -### Outbound — runtime → control plane drafts +### Outbound — runtime → control plane ```bash -uv run freeos org skills generate # if you have not imported the catalog yet uv run freeos org assets publish +uv run freeos org assets apply uv run freeos org employee export-profile my-analyst --tenant-id 1 ``` -`assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`: - -- `skills/` — generated / polished SKILL.md trees -- `plugins/` — tenant-toggleable plugin drafts (`enabled: false`) -- `mcps/` — `xyos-governance-mcp` stdio snippet -- `agents/` — compiled colleague workspaces -- `openxyos/` — payloads shaped for `/api/plugins`, `/api/module-settings`, `/api/employees`, `/api/talent` (`org-employees.publish.json`, `org-talent.publish.json`) -- `manifest.json` — `freeos.asset-pack.v1` - -Packed agent `.env` files keep only tenant/slug/schema keys. Secrets are stripped. - -Nothing is auto-enabled on the sidecar. You review the draft, then toggle -per tenant. - -`export-profile` writes an HR/capability digest locally and **tries** -`POST /api/employees`. If the sidecar is down, the local draft is the -record (SQL.js is not the production database). +`assets publish` writes `{FREEOS_HOME}/asset-packs/latest/`. +`assets apply` POSTs/PUTs those drafts to the control plane when reachable and always writes `{FREEOS_HOME}/openxyos-mirror//`. Nothing is auto-enabled on the sidecar. ## Where files live | What | Path | |---|---| | Tenant tree | `{FREEOS_HOME}/tenants//` | -| Colleague workspace | `…/employees//` (`SOUL.md`, `MEMORY.md`, `skills/`, `knowledge/`, `.env`, `cron.json`) | +| Colleague workspace | `…/employees//` (`SOUL.md`, `MEMORY.md`, `skills/`, `knowledge/`, `.env`, `cron.json`, `agent.json`) | | Lifecycle registry | `…/employees/registry.json` | +| Chat routing | `…/routing.json` | | Generated module skills | `{FREEOS_HOME}/org-skills/` | +| Spawned agents | `{FREEOS_HOME}/org-agents/` and the host `agents` table | | Governance pauses / audit | `{FREEOS_HOME}/governance/` | -| Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` (engine loads this) | +| Imported policy matrix | `{FREEOS_HOME}/governance/imported-policies.json` | | Asset pack | `{FREEOS_HOME}/asset-packs/latest/` | +| Control-plane mirror | `{FREEOS_HOME}/openxyos-mirror//` | +| Loop proof | `{FREEOS_HOME}/asset-packs/loop-proof.json` | ## High-risk actions -Outbound, delete, pay, and production changes still go through -`xyos-governance-mcp`. If `execute` is false, the tool must not run. -See `src/octop/modules/org_os/governance/README.md`. - -## What this run does not do - -Chat/session routing (Web ↔ IM), reflections ↔ harness-memory as a live -learning loop, and org-as-code GitOps stay **Phase C / architecture -only**. The loop above is enough to grow colleagues and a capability -catalog on disk. +Outbound, delete, pay, and production changes go through `OrgGovernanceMiddleware` on the host tool path and `xyos-governance-mcp`. If `execute` is false, the tool does not run. See `src/octop/modules/org_os/governance/README.md`. diff --git a/scripts/org-loop.sh b/scripts/org-loop.sh new file mode 100755 index 00000000..eb334fcb --- /dev/null +++ b/scripts/org-loop.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Prove the finished FreeOS self-growth loop (fixtures when openXYOS is down). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +uv run freeos org loop run "$@" diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index fcc5413e..e557c447 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -341,3 +341,80 @@ async def import_assets( except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return imported.to_dict() + + +class AssetApplyBody(BaseModel): + pack_dir: str | None = None + tenant_id: str = "" + base_url: str = "" + + +class LoopRunBody(BaseModel): + tenant_id: str = "1" + blueprint_path: str | None = None + policies_path: str | None = None + base_url: str = "" + + +class EmployeeSpawnBody(BaseModel): + slug: str + tenant_id: str = "" + + +@router.post("/assets/apply", summary="Apply an asset pack to openXYOS (HTTP + local mirror)") +async def apply_assets( + body: AssetApplyBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from pathlib import Path + + from octop.modules.org_os.apply.apply import apply_asset_pack + + service = _service(server) + dest = Path(body.pack_dir) if body.pack_dir else service.home / "asset-packs" / "latest" + return apply_asset_pack( + dest, + home=service.home, + tenant_id=body.tenant_id or service.tenant_id() or "default", + base_url=body.base_url, + ).to_dict() + + +@router.post("/employees/spawn", summary="Register a compiled colleague as a FreeOS chat agent") +async def spawn_employee( + body: EmployeeSpawnBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from octop.modules.org_os.lifecycle.store import LifecycleStore + from octop.modules.org_os.runtime.spawn import spawn_colleague_agent + + service = _service(server) + tid = body.tenant_id or service.tenant_id() or "default" + record = LifecycleStore(service.home, tid).get(body.slug) + if record is None: + raise HTTPException(status_code=404, detail=f"unknown colleague: {body.slug}") + return spawn_colleague_agent(service.home, record).to_dict() + + +@router.post("/loop/run", summary="Run the finished FreeOS self-growth loop") +async def run_loop( + body: LoopRunBody, + server: OctopServer = Depends(get_server), + _user: Any = Depends(require_permission("plugins")), +) -> dict[str, Any]: + from pathlib import Path + + from octop.modules.org_os.loop.run import run_growth_loop + + service = _service(server) + proof = run_growth_loop( + service.home, + tenant_id=body.tenant_id or service.tenant_id() or "1", + 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.sidecar_url(), + config_path=service.config_path, + ) + return proof.to_dict() diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 61686127..7283568f 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -1,4 +1,4 @@ -"""``freeos org`` / ``octop org`` — organization module, governance, skills.""" +"""``freeos org`` — organization self-growth loop, governance, and skills.""" from __future__ import annotations @@ -238,8 +238,18 @@ def employee_spawn(blueprint_path: Path, tenant_id: str) -> None: workspace=compiled.workspace, lifecycle="draft", ) + from octop.modules.org_os.runtime.spawn import spawn_colleague_agent + + spawned = spawn_colleague_agent(service.home, record) click.echo( - json.dumps({"compiled": compiled.to_dict(), "lifecycle": record.to_dict()}, indent=2) + json.dumps( + { + "compiled": compiled.to_dict(), + "lifecycle": record.to_dict(), + "agent": spawned.to_dict(), + }, + indent=2, + ) ) click.echo("Next: freeos org employee transition market|recruit|shadow|active") @@ -342,3 +352,56 @@ def assets_import( from_sidecar=from_sidecar, ) click.echo(json.dumps(imported.to_dict(), indent=2)) + + +@org_assets.command("apply") +@click.option("--pack", "pack_dir", type=click.Path(path_type=Path, exists=True), default=None) +@click.option("--tenant-id", default="") +@click.option("--base-url", default="", help="openXYOS origin; also reads OPENXYOS_BASE_URL.") +def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: + """Apply a published pack to openXYOS (HTTP when reachable, always a local mirror).""" + from octop.modules.org_os.apply.apply import apply_asset_pack + + service = _service() + dest = pack_dir or (service.home / "asset-packs" / "latest") + result = apply_asset_pack( + dest, + home=service.home, + tenant_id=tenant_id or service.tenant_id() or "default", + base_url=base_url, + ) + click.echo(json.dumps(result.to_dict(), indent=2)) + + +@org.group("loop") +def org_loop() -> None: + """Finished self-growth loop: produce → assemble → feed back → govern.""" + + +@org_loop.command("run") +@click.option("--tenant-id", default="1") +@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.", +) +def loop_run( + tenant_id: str, blueprint_path: Path | None, policies_path: Path | None, base_url: str +) -> None: + """Run the full FreeOS ↔ openXYOS loop with fixtures when the sidecar is down.""" + from octop.modules.org_os.loop.run import run_growth_loop + + service = _service() + proof = run_growth_loop( + service.home, + tenant_id=tenant_id or service.tenant_id() or "1", + blueprint_path=blueprint_path, + policies_path=policies_path, + sidecar_url=base_url or service.sidecar_url(), + config_path=service.config_path, + ) + click.echo(json.dumps(proof.to_dict(), indent=2)) + if not proof.ok: + raise SystemExit(2) diff --git a/src/octop/cli/main.py b/src/octop/cli/main.py index bd04c135..8f7a60d1 100644 --- a/src/octop/cli/main.py +++ b/src/octop/cli/main.py @@ -94,7 +94,7 @@ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> is_eager=True, expose_value=False, callback=_print_version, - help="Show the installed octop version.", + help="Show the installed FreeOS / octop version.", ) @click.option( "--user", @@ -124,7 +124,7 @@ def cli( agent_id: str | None, json_out: bool, ) -> None: - """Octop command-line interface.""" + """FreeOS command-line interface (octop-compatible).""" _ensure_utf8_stdio() ctx.ensure_object(dict) ctx.obj["as_user"] = as_user diff --git a/src/octop/cli/registry.py b/src/octop/cli/registry.py index c2bda4b3..815890b7 100644 --- a/src/octop/cli/registry.py +++ b/src/octop/cli/registry.py @@ -31,6 +31,6 @@ "org": ( ".commands.org", "org", - "Organization module, governance MCP, and module↔skill bridge.", + "Organization self-growth loop, governance, and module↔skill bridge.", ), } diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 40ff4db0..8488dea8 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -845,7 +845,14 @@ def resolve_user_agent(self, user_id: int, query: str) -> AgentRow | None: if row.name.lower() == ql: return row partial = [r for r in rows if ql in r.name.lower()] - return partial[0] if len(partial) == 1 else None + if len(partial) == 1: + return partial[0] + from octop.modules.org_os.runtime.routing import resolve_routed_agent + + routed = resolve_routed_agent(self._paths.root, q) + if routed: + return self.get_row(routed) + return None def get_config(self, agent_id: str) -> dict[str, Any]: """Return harness config for agent_id, overlaying column skill_package_ids.""" @@ -2749,6 +2756,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: from octop.infra.agents.middleware.binary_read_guard import BinaryReadGuardMiddleware from octop.infra.agents.middleware.browser_profile import BrowserProfileMiddleware + from octop.infra.agents.middleware.org_governance import OrgGovernanceMiddleware from octop.infra.agents.middleware.reasoning import ReasoningRequestMiddleware from octop.infra.agents.middleware.thread_artifacts import ThreadArtifactsMiddleware from octop.infra.agents.middleware.token_quota import TokenQuotaMiddleware @@ -2763,6 +2771,7 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: # WorkspaceImageMaterialize expands path-only vision refs at model-call time. agent_middleware: list[Any] = [ *plugin_middleware, + OrgGovernanceMiddleware.from_home(self._paths.root, config_path=self._paths.config), TokenQuotaMiddleware( policy_repo=self._repos.user_policy_repo, usage_repo=self._repos.usage_repo, diff --git a/src/octop/infra/agents/middleware/org_governance.py b/src/octop/infra/agents/middleware/org_governance.py new file mode 100644 index 00000000..d5cec446 --- /dev/null +++ b/src/octop/infra/agents/middleware/org_governance.py @@ -0,0 +1,129 @@ +"""Host-wide governance gate on the FreeOS/Octop tool path. + +High-risk tool calls (outbound / delete / pay / prod) go through +``xyos-governance-mcp`` before the handler runs. A pending or deny +decision returns an error ToolMessage — the tool does not execute. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from langchain.agents.middleware import AgentMiddleware +from langchain_core.messages import ToolMessage +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.types import Command + +from octop.modules.org_os.governance.interceptor import ( + GovernanceBlockedError, + gate_tool_call, +) +from octop.modules.org_os.governance.policy import is_high_risk +from octop.modules.org_os.service import OrgModuleService + +logger = logging.getLogger(__name__) + + +def host_gate_tool( + *, + home: Path, + tool_name: str, + args: dict[str, Any] | None = None, + tenant_id: str = "", + actor_id: str = "", + approval_id: str = "", + sidecar_url: str = "", + enabled: bool = True, +) -> None: + """Raise ``GovernanceBlockedError`` when a high-risk host tool must not run.""" + if not enabled or not is_high_risk(tool_name): + return + gate_tool_call( + home=home, + tool_name=tool_name, + action=tool_name, + tenant_id=tenant_id, + actor_id=actor_id, + args=args or {}, + approval_id=approval_id, + sidecar_url=sidecar_url, + enabled=True, + ) + + +class OrgGovernanceMiddleware(AgentMiddleware[Any, Any]): + """Block high-risk harness tool calls when org governance is enabled.""" + + def __init__( + self, + *, + home: Path, + sidecar_url: str = "", + tenant_id: str = "", + enabled: bool = True, + actor_id: str = "", + ) -> None: + super().__init__() + self.home = Path(home) + self.sidecar_url = sidecar_url + self.tenant_id = tenant_id + self.enabled = enabled + self.actor_id = actor_id + + @classmethod + def from_home(cls, home: Path, *, config_path: Path | None = None) -> OrgGovernanceMiddleware: + service = OrgModuleService(config_path=config_path or (home / "config.json"), home=home) + return cls( + home=home, + sidecar_url=service.sidecar_url(), + tenant_id=service.tenant_id(), + enabled=service.governance_enabled() or service.is_enabled(), + ) + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + tool_call = request.tool_call + tool_name = str(tool_call.get("name") or "") + raw_args = tool_call.get("args") + args = raw_args if isinstance(raw_args, dict) else {} + approval_id = str(args.get("approval_id") or args.get("governance_approval_id") or "") + try: + host_gate_tool( + home=self.home, + tool_name=tool_name, + args=args, + tenant_id=self.tenant_id, + actor_id=self.actor_id, + approval_id=approval_id, + sidecar_url=self.sidecar_url, + enabled=self.enabled, + ) + except GovernanceBlockedError as exc: + logger.info("OrgGovernance blocked %s: %s", tool_name, exc.decision.reason) + payload = json_payload(exc.decision.to_dict()) + return ToolMessage( + content=payload, + tool_call_id=str(tool_call.get("id") or ""), + status="error", + ) + return await handler(request) + + +def json_payload(data: dict[str, Any]) -> str: + import json + + return json.dumps( + { + "blocked": True, + "execute": False, + "governance": data, + "message": data.get("reason") or "FreeOS governance blocked this high-risk tool.", + }, + ensure_ascii=False, + ) diff --git a/src/octop/modules/org_os/apply/__init__.py b/src/octop/modules/org_os/apply/__init__.py new file mode 100644 index 00000000..b53df786 --- /dev/null +++ b/src/octop/modules/org_os/apply/__init__.py @@ -0,0 +1,16 @@ +"""Apply FreeOS asset packs onto openXYOS-shaped control-plane surfaces.""" + +from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces +from octop.modules.org_os.apply.client import ( + ApplyResult, + OpenXyosControlClient, + resolve_control_plane_url, +) + +__all__ = [ + "ApplyResult", + "OpenXyosControlClient", + "apply_asset_pack", + "import_applied_surfaces", + "resolve_control_plane_url", +] diff --git a/src/octop/modules/org_os/apply/apply.py b/src/octop/modules/org_os/apply/apply.py new file mode 100644 index 00000000..ca378917 --- /dev/null +++ b/src/octop/modules/org_os/apply/apply.py @@ -0,0 +1,200 @@ +"""Apply a ``freeos.asset-pack.v1`` onto openXYOS-shaped surfaces.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from octop.modules.org_os.apply.client import ( + ApplyReceipt, + ApplyResult, + OpenXyosControlClient, + mirror_root, +) +from octop.modules.org_os.catalog import catalog_keys + + +def _load_json(path: Path) -> Any: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + + +def _module_settings_payload(tenant_id: str) -> dict[str, Any]: + updates = dict.fromkeys(catalog_keys(), False) + return { + "enabled_by_default": False, + "tenant_id": tenant_id, + "updates": updates, + "note": "Drafts only. Operator must toggle each module on.", + } + + +def apply_asset_pack( + pack_dir: Path, + *, + home: Path, + tenant_id: str = "", + base_url: str = "", + headers: dict[str, str] | None = None, +) -> ApplyResult: + """Write local mirror + POST/PUT drafts to module-settings/plugins/employees/talent.""" + dest = pack_dir if pack_dir.is_dir() else Path(pack_dir) + tid = tenant_id or "default" + mirror = mirror_root(home, tid) + mirror.mkdir(parents=True, exist_ok=True) + client = OpenXyosControlClient(base_url, headers=headers) + result = ApplyResult( + pack_dir=dest, + mirror_dir=mirror, + control_plane_url=client.base_url, + notes=[ + "Local mirror is the durable record. HTTP apply is best-effort.", + "Nothing is auto-enabled on the control plane.", + ], + ) + + employees_src = dest / "openxyos" / "org-employees.publish.json" + employees = _load_json(employees_src) or {"employees": []} + emp_mirror = client.write_mirror(mirror / "employees.json", employees) + emp_items = employees.get("employees") if isinstance(employees, dict) else [] + if not isinstance(emp_items, list): + emp_items = [] + http = client.request( + "POST", + "/api/employees", + payload={ + "employees": emp_items, + "enabled_by_default": False, + "source": "freeos.asset-pack.v1", + }, + ) + if emp_items and client.base_url: + # Also upsert each employee the way openXYOS POST /api/employees expects. + for item in emp_items: + if isinstance(item, dict): + client.request("POST", "/api/employees", payload=item) + result.receipts.append( + ApplyReceipt( + surface="employees", + endpoint="/api/employees", + mirrored=str(emp_mirror), + http=http, + item_count=len(emp_items), + ) + ) + + talent_src = dest / "openxyos" / "org-talent.publish.json" + talent = _load_json(talent_src) or {"talent": []} + talent_mirror = client.write_mirror(mirror / "talent.json", talent) + talent_items = talent.get("talent") if isinstance(talent, dict) else [] + if not isinstance(talent_items, list): + talent_items = [] + http = client.request( + "POST", + "/api/talent", + payload={ + "talent": talent_items, + "enabled_by_default": False, + "source": "freeos.asset-pack.v1", + }, + ) + result.receipts.append( + ApplyReceipt( + surface="talent", + endpoint="/api/talent", + mirrored=str(talent_mirror), + http=http, + item_count=len(talent_items), + ) + ) + + plugin_payloads: list[dict[str, Any]] = [] + openxyos_dir = dest / "openxyos" + if openxyos_dir.is_dir(): + for path in sorted(openxyos_dir.glob("*.publish.json")): + if path.name.startswith("org-employees") or path.name.startswith("org-talent"): + continue + raw = _load_json(path) + if isinstance(raw, dict): + plugin_payloads.append(raw) + plugins_doc = {"enabled_by_default": False, "plugins": plugin_payloads} + plugins_mirror = client.write_mirror(mirror / "plugins.json", plugins_doc) + http = client.request("POST", "/api/plugins", payload=plugins_doc) + result.receipts.append( + ApplyReceipt( + surface="plugins", + endpoint="/api/plugins", + mirrored=str(plugins_mirror), + http=http, + item_count=len(plugin_payloads), + ) + ) + + settings = _module_settings_payload(tid) + settings_mirror = client.write_mirror(mirror / "module-settings.json", settings) + http = client.request("PUT", "/api/module-settings", payload=settings) + result.receipts.append( + ApplyReceipt( + surface="module-settings", + endpoint="/api/module-settings", + mirrored=str(settings_mirror), + http=http, + item_count=len(settings.get("updates") or {}), + ) + ) + + receipt_path = mirror / "apply-receipt.json" + client.write_mirror(receipt_path, result.to_dict()) + if client.base_url and not result.remote_applied: + result.notes.append( + 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") + return result + + +def import_applied_surfaces( + home: Path, + *, + tenant_id: str = "", + base_url: str = "", +) -> dict[str, Any]: + """Read the durable mirror (and live HTTP when up) after apply. + + This is the inbound half of the apply+import roundtrip harness. + """ + tid = tenant_id or "default" + mirror = mirror_root(home, tid) + client = OpenXyosControlClient(base_url) + remote: dict[str, Any] = {} + if client.base_url: + for key, path in ( + ("employees", "/api/employees"), + ("talent", "/api/talent"), + ("plugins", "/api/plugins"), + ("module_settings", "/api/module-settings"), + ("permissions", "/api/governance/permissions"), + ): + fetched = client.get_json(path) + if fetched is not None: + remote[key] = fetched + client.write_mirror(mirror / f"imported-{key}.json", fetched) + + local: dict[str, Any] = {} + for name in ("employees", "talent", "plugins", "module-settings", "apply-receipt"): + raw = _load_json(mirror / f"{name}.json") + if raw is not None: + local[name.replace("-", "_")] = raw + return { + "tenant_id": tid, + "mirror_dir": str(mirror), + "control_plane_url": client.base_url, + "local": local, + "remote": remote, + } diff --git a/src/octop/modules/org_os/apply/client.py b/src/octop/modules/org_os/apply/client.py new file mode 100644 index 00000000..3d8a9a7a --- /dev/null +++ b/src/octop/modules/org_os/apply/client.py @@ -0,0 +1,185 @@ +"""openXYOS control-plane HTTP client with a durable local mirror. + +Live HTTP is used when ``OPENXYOS_BASE_URL`` / ``FREEOS_ORG_SIDECAR_URL`` is a +real origin. A down sidecar never fails the loop open: every payload is written +under ``{FREEOS_HOME}/openxyos-mirror//`` first. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +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.""" + ordered = [ + os.environ.get("OPENXYOS_BASE_URL", "").strip(), + *candidates, + os.environ.get("FREEOS_ORG_SIDECAR_URL", "").strip(), + ] + for raw in ordered: + cleaned = (raw or "").strip().rstrip("/") + parsed = urlparse(cleaned) + if parsed.scheme in {"http", "https"} and parsed.netloc: + return cleaned + return "" + + +def mirror_root(home: Path, tenant_id: str) -> Path: + tid = tenant_id or "default" + return home / "openxyos-mirror" / tid + + +@dataclass +class HttpResult: + ok: bool + reached: bool + method: str + path: str + status_code: int = 0 + reason: str = "" + body: Any = None + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "reached": self.reached, + "method": self.method, + "path": self.path, + "status_code": self.status_code, + "reason": self.reason, + } + + +class OpenXyosControlClient: + """POST/PUT/GET openXYOS-shaped surfaces. Always persist a local mirror.""" + + def __init__( + self, + base_url: str = "", + *, + timeout: float = 3.0, + headers: dict[str, str] | None = None, + ) -> None: + self.base_url = resolve_control_plane_url(base_url) + self.timeout = timeout + self.headers = dict(headers or {}) + + def write_mirror(self, dest: Path, payload: Any) -> Path: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return dest + + def request( + self, + method: str, + path: str, + *, + payload: Any | None = None, + mirror_path: Path | None = None, + ) -> HttpResult: + if mirror_path is not None and payload is not None: + self.write_mirror(mirror_path, payload) + if not self.base_url: + return HttpResult( + ok=False, + reached=False, + method=method, + path=path, + reason="control-plane URL unset; local mirror only", + ) + url = f"{self.base_url}{path if path.startswith('/') else '/' + path}" + try: + with httpx.Client(timeout=self.timeout, follow_redirects=True) as client: + response = client.request( + method.upper(), + url, + json=payload, + headers=self.headers or None, + ) + except httpx.HTTPError as exc: + return HttpResult( + ok=False, + reached=False, + method=method, + path=path, + reason=f"control plane unreachable: {exc}", + ) + body: Any + try: + body = response.json() + except ValueError: + body = response.text + return HttpResult( + ok=response.status_code < 400, + reached=True, + method=method, + path=path, + status_code=response.status_code, + reason="" if response.status_code < 400 else f"HTTP {response.status_code}", + body=body, + ) + + def get_json(self, path: str) -> Any | None: + result = self.request("GET", path) + if not result.ok: + return None + body = result.body + if isinstance(body, dict) and "data" in body: + return body["data"] + return body + + +@dataclass +class ApplyReceipt: + surface: str + endpoint: str + mirrored: str + http: HttpResult + item_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "surface": self.surface, + "endpoint": self.endpoint, + "mirrored": self.mirrored, + "item_count": self.item_count, + "http": self.http.to_dict(), + } + + +@dataclass +class ApplyResult: + pack_dir: Path + mirror_dir: Path + receipts: list[ApplyReceipt] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + control_plane_url: str = "" + + @property + def remote_applied(self) -> bool: + return any(item.http.ok for item in self.receipts) + + @property + def mirrored(self) -> bool: + return self.mirror_dir.is_dir() and any(self.mirror_dir.rglob("*.json")) + + def to_dict(self) -> dict[str, Any]: + return { + "pack_dir": str(self.pack_dir), + "mirror_dir": str(self.mirror_dir), + "control_plane_url": self.control_plane_url, + "remote_applied": self.remote_applied, + "mirrored": self.mirrored, + "receipts": [item.to_dict() for item in self.receipts], + "notes": list(self.notes), + } diff --git a/src/octop/modules/org_os/assets/importer.py b/src/octop/modules/org_os/assets/importer.py index 747828b5..d1132a5e 100644 --- a/src/octop/modules/org_os/assets/importer.py +++ b/src/octop/modules/org_os/assets/importer.py @@ -22,6 +22,7 @@ class ImportedAssets: skills: list[str] = field(default_factory=list) employees: list[str] = field(default_factory=list) + agents: list[str] = field(default_factory=list) policies: str = "" notes: list[str] = field(default_factory=list) @@ -29,6 +30,7 @@ def to_dict(self) -> dict[str, Any]: return { "skills": list(self.skills), "employees": list(self.employees), + "agents": list(self.agents), "policies": self.policies, "notes": list(self.notes), } @@ -64,6 +66,7 @@ def import_openxyos_assets( blueprint_path: Path | None = None, policies_path: Path | None = None, from_sidecar: bool = False, + spawn_agents: bool = True, ) -> ImportedAssets: result = ImportedAssets() tid = tenant_id or "default" @@ -86,6 +89,14 @@ def import_openxyos_assets( ) result.employees.append(compiled.slug) result.notes.append(f"compiled blueprint → {compiled.workspace}") + if spawn_agents: + from octop.modules.org_os.runtime.spawn import spawn_colleague_agent + + record = store.get(compiled.slug) + if record is not None: + spawned = spawn_colleague_agent(home, record) + result.agents.append(spawned.agent_id) + result.notes.append(f"spawned FreeOS agent {spawned.agent_id}") policies_raw: Any | None = None policies_source = "" diff --git a/src/octop/modules/org_os/compiler/compile.py b/src/octop/modules/org_os/compiler/compile.py index 4212f2e4..3727bab1 100644 --- a/src/octop/modules/org_os/compiler/compile.py +++ b/src/octop/modules/org_os/compiler/compile.py @@ -61,7 +61,7 @@ def _render_soul(blueprint: AgentBlueprint) -> str: "", "## Governance (must follow)", "", - "You run on the FreeOS/Octop **data plane**. openXYOS is the control plane.", + "You run on the FreeOS **data plane**. openXYOS is the control plane.", "Do not start a second chat runtime.", "", "- High-risk tools (outbound / delete / pay / prod) go through", diff --git a/src/octop/modules/org_os/governance/engine.py b/src/octop/modules/org_os/governance/engine.py index 3a9ef8a2..13d35b03 100644 --- a/src/octop/modules/org_os/governance/engine.py +++ b/src/octop/modules/org_os/governance/engine.py @@ -237,7 +237,7 @@ def _pending_or_deny( status="pending", execute=False, blocked=True, - reason=f"{reason}; paused for human approval via FreeOS/Octop IM or " + reason=f"{reason}; paused for human approval via FreeOS IM or " f"`freeos org governance approve {pause.pause_id}`", category=category, pause_id=pause.pause_id, diff --git a/src/octop/modules/org_os/lifecycle/store.py b/src/octop/modules/org_os/lifecycle/store.py index 08977090..e11c1a6f 100644 --- a/src/octop/modules/org_os/lifecycle/store.py +++ b/src/octop/modules/org_os/lifecycle/store.py @@ -34,6 +34,7 @@ class ColleagueRecord: memory_archived: str = "" openxyos_talent_status: str = "" openxyos_employment_category: str = "" + agent_id: str = "" def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -55,6 +56,7 @@ def from_dict(cls, data: dict[str, Any]) -> ColleagueRecord: memory_archived=str(data.get("memory_archived") or ""), openxyos_talent_status=str(data.get("openxyos_talent_status") or ""), openxyos_employment_category=str(data.get("openxyos_employment_category") or ""), + agent_id=str(data.get("agent_id") or ""), ) diff --git a/src/octop/modules/org_os/lifecycle/transitions.py b/src/octop/modules/org_os/lifecycle/transitions.py index 1fcd88ee..a70a99ef 100644 --- a/src/octop/modules/org_os/lifecycle/transitions.py +++ b/src/octop/modules/org_os/lifecycle/transitions.py @@ -115,6 +115,14 @@ def transition( _apply_flags(record) if workspace.is_dir(): _set_cron_enabled(workspace, record.cron_enabled) + from octop.modules.org_os.runtime.memory import sync_colleague_memory + + sync_colleague_memory(record, store.home) + if target in {"shadow", "active"} and not record.agent_id: + from octop.modules.org_os.runtime.spawn import spawn_colleague_agent + + spawned = spawn_colleague_agent(store.home, record) + record.agent_id = spawned.agent_id if target == "offboard": _revoke_credentials(workspace) record.credentials_revoked = True diff --git a/src/octop/modules/org_os/loop/__init__.py b/src/octop/modules/org_os/loop/__init__.py new file mode 100644 index 00000000..9c79b637 --- /dev/null +++ b/src/octop/modules/org_os/loop/__init__.py @@ -0,0 +1,5 @@ +"""Finished FreeOS self-growth loop.""" + +from octop.modules.org_os.loop.run import LoopProof, run_growth_loop + +__all__ = ["LoopProof", "run_growth_loop"] diff --git a/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json b/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json new file mode 100644 index 00000000..6d25cd48 --- /dev/null +++ b/src/octop/modules/org_os/loop/data/agent-blueprint.v1.json @@ -0,0 +1,28 @@ +{ + "schema": "openxyos.agent-blueprint.v1", + "name": "Policy Analyst", + "industry": "public-sector", + "positioning": "Reads org policy and drafts briefings; never sends mail or changes production.", + "capabilities": ["policy-research", "briefing"], + "experience": "Summarize policy diffs. Cite sources. Escalate high-risk asks.", + "references": [ + { + "name": "staff-handbook.md", + "type": ".md", + "scan": "clean", + "excerpt": "Staff must not share tenant data across workspaces." + } + ], + "governance": { + "high_risk_requires_human_review": true, + "external_write_disabled": true + }, + "lifecycle": "talent_market", + "job_duties": [ + { + "name": "morning-brief", + "schedule": "0 9 * * 1-5", + "prompt": "Draft the weekday policy briefing from knowledge/." + } + ] +} diff --git a/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json b/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json new file mode 100644 index 00000000..23574eb7 --- /dev/null +++ b/src/octop/modules/org_os/loop/data/ops-coordinator.blueprint.json @@ -0,0 +1,28 @@ +{ + "schema": "openxyos.agent-blueprint.v1", + "name": "Ops Coordinator", + "industry": "professional-services", + "positioning": "Coordinates org-module work and drafts runbooks; never deploys or pays.", + "capabilities": ["runbook-draft", "status-digest"], + "experience": "Keep a live ops digest. Escalate high-risk production or payment asks.", + "references": [ + { + "name": "ops-guardrails.md", + "type": ".md", + "scan": "clean", + "excerpt": "High-risk tools require a durable FreeOS governance approval." + } + ], + "governance": { + "high_risk_requires_human_review": true, + "external_write_disabled": true + }, + "lifecycle": "talent_market", + "job_duties": [ + { + "name": "ops-digest", + "schedule": "0 18 * * 1-5", + "prompt": "Summarize today's org-module activity from knowledge/." + } + ] +} diff --git a/src/octop/modules/org_os/loop/data/policies.json b/src/octop/modules/org_os/loop/data/policies.json new file mode 100644 index 00000000..b4ab68dc --- /dev/null +++ b/src/octop/modules/org_os/loop/data/policies.json @@ -0,0 +1,26 @@ +{ + "source": "freeos.demo-fixture", + "rules": [ + { + "category": "delete", + "action": "delete_employee", + "allow": false, + "reason": "Demo policy: deleting employees is denied until a human approves." + }, + { + "category": "pay", + "allow": false, + "reason": "Demo policy: payments stay default-deny." + }, + { + "category": "outbound", + "allow": false, + "reason": "Demo policy: outbound send is gated." + }, + { + "category": "prod", + "allow": false, + "reason": "Demo policy: production changes stay gated." + } + ] +} diff --git a/src/octop/modules/org_os/loop/fixtures.py b/src/octop/modules/org_os/loop/fixtures.py new file mode 100644 index 00000000..cbb71ad5 --- /dev/null +++ b/src/octop/modules/org_os/loop/fixtures.py @@ -0,0 +1,58 @@ +"""Demo fixture dataset for the finished FreeOS self-growth loop.""" + +from __future__ import annotations + +from pathlib import Path + +_PACKAGED = Path(__file__).resolve().parent / "data" + + +def _repo_root() -> Path | None: + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "tests" / "fixtures").is_dir() and (parent / "pyproject.toml").is_file(): + return parent + return None + + +def _repo_loop_dir() -> Path | None: + root = _repo_root() + if root is None: + return None + path = root / "tests" / "fixtures" / "org-loop" + return path if path.is_dir() else None + + +def blueprint_fixture() -> Path: + for candidate in ( + _PACKAGED / "agent-blueprint.v1.json", + (_repo_loop_dir() or Path()) / "agent-blueprint.v1.json", + ): + if candidate.is_file(): + return candidate + raise FileNotFoundError("bundled agent-blueprint.v1.json is missing") + + +def policies_fixture() -> Path: + for candidate in ( + _PACKAGED / "policies.json", + (_repo_loop_dir() or Path()) / "policies.json", + ): + if candidate.is_file(): + return candidate + raise FileNotFoundError("bundled policies.json is missing") + + +def extra_blueprint_fixtures() -> list[Path]: + found: list[Path] = [] + seen_names: set[str] = set() + for path in sorted(_PACKAGED.glob("*.blueprint.json")): + if path.name in seen_names: + continue + seen_names.add(path.name) + found.append(path) + return found + + +def fixture_dir() -> Path: + return _repo_loop_dir() or _PACKAGED diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py new file mode 100644 index 00000000..2653af69 --- /dev/null +++ b/src/octop/modules/org_os/loop/run.py @@ -0,0 +1,208 @@ +"""One operator path: blueprint → employee → publish → apply → import → agents.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from octop.modules.org_os.apply.apply import apply_asset_pack, import_applied_surfaces +from octop.modules.org_os.apply.client import resolve_control_plane_url +from octop.modules.org_os.assets.importer import import_openxyos_assets +from octop.modules.org_os.assets.pack import publish_asset_pack +from octop.modules.org_os.governance.interceptor import ( + GovernanceBlockedError, + gate_tool_call, +) +from octop.modules.org_os.lifecycle.store import LifecycleStore +from octop.modules.org_os.lifecycle.transitions import transition +from octop.modules.org_os.loop.fixtures import ( + blueprint_fixture, + extra_blueprint_fixtures, + policies_fixture, +) +from octop.modules.org_os.runtime.spawn import SpawnedAgent, spawn_colleague_agent +from octop.modules.org_os.service import OrgModuleService + + +@dataclass +class LoopProof: + tenant_id: str + home: str + skills: list[str] = field(default_factory=list) + employees: list[str] = field(default_factory=list) + agents: list[dict[str, Any]] = field(default_factory=list) + lifecycle: dict[str, str] = field(default_factory=dict) + pack_dir: str = "" + mirror_dir: str = "" + remote_applied: bool = False + imported_roundtrip: dict[str, Any] = field(default_factory=dict) + governance_blocked: bool = False + governance: dict[str, Any] = field(default_factory=dict) + notes: list[str] = field(default_factory=list) + ok: bool = False + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "tenant_id": self.tenant_id, + "home": self.home, + "skills": list(self.skills), + "employees": list(self.employees), + "agents": list(self.agents), + "lifecycle": dict(self.lifecycle), + "pack_dir": self.pack_dir, + "mirror_dir": self.mirror_dir, + "remote_applied": self.remote_applied, + "imported_roundtrip": dict(self.imported_roundtrip), + "governance_blocked": self.governance_blocked, + "governance": dict(self.governance), + "notes": list(self.notes), + } + + +def _promote(store: LifecycleStore, slug: str) -> dict[str, str]: + states = ("market", "recruit", "shadow", "active") + seen: dict[str, str] = {"draft": "draft"} + for state in states: + record = transition(store, slug, state, reason="org loop run") + seen[state] = record.lifecycle + return seen + + +def run_growth_loop( + home: Path, + *, + tenant_id: str = "1", + blueprint_path: Path | None = None, + policies_path: Path | None = None, + sidecar_url: str = "", + config_path: Path | None = None, +) -> LoopProof: + """Run the finished self-growth loop against *home*. + + Uses bundled fixtures when live openXYOS is down. When + ``OPENXYOS_BASE_URL`` (or *sidecar_url*) is set, outbound packs are + POSTed to the real control plane as well as the durable local mirror. + """ + 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) + service.set_enabled(True, sidecar_url=sidecar_url or None) + service.set_governance_enabled(True, tenant_id=tid) + + blueprint = Path(blueprint_path) if blueprint_path else blueprint_fixture() + 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, service.sidecar_url()) + + imported = import_openxyos_assets( + home, + tenant_id=tid, + sidecar_url=control_url or service.sidecar_url(), + catalog=True, + blueprint_path=blueprint, + policies_path=policies if policies.is_file() else None, + from_sidecar=bool(control_url), + spawn_agents=False, + ) + for extra in extra_blueprint_fixtures(): + more = import_openxyos_assets( + home, + tenant_id=tid, + sidecar_url=control_url or service.sidecar_url(), + blueprint_path=extra, + spawn_agents=False, + ) + imported.employees.extend(more.employees) + imported.notes.extend(more.notes) + + store = LifecycleStore(home, tid) + lifecycle: dict[str, str] = {} + spawned: list[SpawnedAgent] = [] + for slug in imported.employees: + lifecycle.update(_promote(store, slug)) + record = store.get(slug) + if record is None: + continue + spawned.append(spawn_colleague_agent(home, record)) + + pack = publish_asset_pack(home, tenant_id=tid) + applied = apply_asset_pack( + pack.directory, + home=home, + tenant_id=tid, + base_url=control_url, + ) + roundtrip = import_applied_surfaces(home, tenant_id=tid, base_url=control_url) + + # Inbound feedback: re-import catalog/policies from the applied pack so + # the control plane strengthens the next FreeOS generation. + feedback = import_openxyos_assets( + home, + tenant_id=tid, + sidecar_url=control_url or service.sidecar_url(), + catalog=True, + from_sidecar=bool(roundtrip.get("remote")), + spawn_agents=True, + ) + + governance: dict[str, Any] = {} + blocked = False + try: + gate_tool_call( + home=home, + tool_name="delete_employee", + category="delete", + action="delete", + tenant_id=tid, + actor_id="org-loop", + args={"slug": imported.employees[0] if imported.employees else "demo"}, + sidecar_url=control_url, + enabled=True, + ) + except GovernanceBlockedError as exc: + blocked = True + governance = exc.decision.to_dict() + + proof = LoopProof( + tenant_id=tid, + home=str(home), + skills=list(dict.fromkeys([*imported.skills, *feedback.skills])), + employees=list(imported.employees), + agents=[item.to_dict() for item in spawned], + lifecycle=lifecycle, + pack_dir=str(pack.directory), + mirror_dir=str(applied.mirror_dir), + remote_applied=applied.remote_applied, + imported_roundtrip={ + "local_surfaces": sorted((roundtrip.get("local") or {}).keys()), + "remote_surfaces": sorted((roundtrip.get("remote") or {}).keys()), + "feedback_skills": list(feedback.skills), + "feedback_employees": list(feedback.employees), + }, + governance_blocked=blocked, + governance=governance, + notes=[ + *imported.notes, + *applied.notes, + *feedback.notes, + "Loop produced employees, published an asset pack, applied it, and imported back.", + ], + ) + proof.ok = bool( + proof.skills + and proof.employees + and proof.agents + and proof.lifecycle.get("active") == "active" + and proof.pack_dir + and applied.mirrored + and proof.governance_blocked + ) + dest = home / "asset-packs" / "loop-proof.json" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(json.dumps(proof.to_dict(), indent=2) + "\n", encoding="utf-8") + return proof diff --git a/src/octop/modules/org_os/runtime/__init__.py b/src/octop/modules/org_os/runtime/__init__.py new file mode 100644 index 00000000..6ad1f0ce --- /dev/null +++ b/src/octop/modules/org_os/runtime/__init__.py @@ -0,0 +1,18 @@ +"""Runtime hooks: spawn colleagues as FreeOS agents, route chat, sync org memory.""" + +from octop.modules.org_os.runtime.memory import sync_colleague_memory +from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent +from octop.modules.org_os.runtime.spawn import ( + SpawnedAgent, + list_spawned_agents, + spawn_colleague_agent, +) + +__all__ = [ + "ColleagueRouter", + "SpawnedAgent", + "list_spawned_agents", + "resolve_routed_agent", + "spawn_colleague_agent", + "sync_colleague_memory", +] diff --git a/src/octop/modules/org_os/runtime/memory.py b/src/octop/modules/org_os/runtime/memory.py new file mode 100644 index 00000000..149f9624 --- /dev/null +++ b/src/octop/modules/org_os/runtime/memory.py @@ -0,0 +1,48 @@ +"""Org memory hooks: colleague MEMORY/knowledge feed the tenant knowledge plane.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from octop.modules.org_os.lifecycle.store import ColleagueRecord + + +def org_knowledge_root(home: Path, tenant_id: str) -> Path: + return home / "tenants" / (tenant_id or "default") / "org-knowledge" + + +def live_memory_dir(home: Path, tenant_id: str, slug: str) -> Path: + return org_knowledge_root(home, tenant_id) / "live" / slug + + +def sync_colleague_memory(record: ColleagueRecord, home: Path) -> Path: + """Copy workspace MEMORY + knowledge into the tenant org-knowledge live tree.""" + dest = live_memory_dir(home, record.tenant_id, record.slug) + dest.mkdir(parents=True, exist_ok=True) + workspace = Path(record.workspace) + memory = workspace / "MEMORY.md" + if memory.is_file(): + shutil.copy2(memory, dest / "MEMORY.md") + else: + (dest / "MEMORY.md").write_text(f"# {record.name}\n", encoding="utf-8") + knowledge = workspace / "knowledge" + knowledge_dest = dest / "knowledge" + if knowledge.is_dir(): + if knowledge_dest.exists(): + shutil.rmtree(knowledge_dest) + shutil.copytree(knowledge, knowledge_dest) + soul = workspace / "SOUL.md" + if soul.is_file(): + shutil.copy2(soul, dest / "SOUL.md") + index = dest / "INDEX.md" + index.write_text( + f"# {record.name}\n\n" + f"- slug: `{record.slug}`\n" + f"- lifecycle: `{record.lifecycle}`\n" + f"- workspace: `{record.workspace}`\n" + f"- agent_id: `{getattr(record, 'agent_id', '')}`\n" + "\nLive org memory used by FreeOS chat routing for this colleague.\n", + encoding="utf-8", + ) + return dest diff --git a/src/octop/modules/org_os/runtime/routing.py b/src/octop/modules/org_os/runtime/routing.py new file mode 100644 index 00000000..978bbf74 --- /dev/null +++ b/src/octop/modules/org_os/runtime/routing.py @@ -0,0 +1,115 @@ +"""Session / chat routing table for produced digital colleagues. + +Maps colleague slugs and display names onto FreeOS ``agent_id`` values so +dashboard chat and IM can address a spawned employee without a second runtime. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +def routing_path(home: Path, tenant_id: str) -> Path: + return home / "tenants" / (tenant_id or "default") / "routing.json" + + +@dataclass +class RouteEntry: + slug: str + agent_id: str + name: str + tenant_id: str + workspace: str + lifecycle: str = "draft" + updated_at: float = 0.0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RouteEntry: + return cls( + slug=str(data.get("slug") or ""), + agent_id=str(data.get("agent_id") or ""), + name=str(data.get("name") or ""), + tenant_id=str(data.get("tenant_id") or ""), + workspace=str(data.get("workspace") or ""), + lifecycle=str(data.get("lifecycle") or "draft"), + updated_at=float(data.get("updated_at") or 0.0), + ) + + +class ColleagueRouter: + def __init__(self, home: Path, tenant_id: str) -> None: + self.home = home + self.tenant_id = tenant_id or "default" + self.path = routing_path(home, self.tenant_id) + + def _load(self) -> dict[str, Any]: + if not self.path.is_file(): + return {"tenant_id": self.tenant_id, "routes": {}} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"tenant_id": self.tenant_id, "routes": {}} + if not isinstance(raw, dict): + return {"tenant_id": self.tenant_id, "routes": {}} + if not isinstance(raw.get("routes"), dict): + raw["routes"] = {} + return raw + + def _save(self, data: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + def upsert(self, entry: RouteEntry) -> RouteEntry: + data = self._load() + entry.updated_at = entry.updated_at or time.time() + data["routes"][entry.slug] = entry.to_dict() + self._save(data) + return entry + + def get(self, slug: str) -> RouteEntry | None: + raw = self._load()["routes"].get(slug) + return RouteEntry.from_dict(raw) if isinstance(raw, dict) else None + + def list(self) -> list[RouteEntry]: + rows = [ + RouteEntry.from_dict(item) + for item in self._load()["routes"].values() + if isinstance(item, dict) + ] + rows.sort(key=lambda item: item.updated_at, reverse=True) + return rows + + def resolve(self, query: str) -> RouteEntry | None: + q = (query or "").strip() + if not q: + return None + ql = q.lower().lstrip("@") + for entry in self.list(): + if entry.slug == ql or entry.agent_id == q or entry.agent_id.lower() == ql: + return entry + if entry.name.lower() == ql: + return entry + partial = [item for item in self.list() if ql in item.slug or ql in item.name.lower()] + return partial[0] if len(partial) == 1 else None + + +def resolve_routed_agent(home: Path, query: str, *, tenant_id: str = "") -> str: + """Return an agent_id for a colleague slug/name, searching one or all tenants.""" + tenants_root = home / "tenants" + tenant_ids = [tenant_id] if tenant_id else [] + if not tenant_ids and tenants_root.is_dir(): + tenant_ids = [path.name for path in tenants_root.iterdir() if path.is_dir()] + if not tenant_ids: + tenant_ids = ["default"] + for tid in tenant_ids: + entry = ColleagueRouter(home, tid).resolve(query) + if entry is not None and entry.agent_id: + return entry.agent_id + return "" diff --git a/src/octop/modules/org_os/runtime/spawn.py b/src/octop/modules/org_os/runtime/spawn.py new file mode 100644 index 00000000..c66aa78b --- /dev/null +++ b/src/octop/modules/org_os/runtime/spawn.py @@ -0,0 +1,253 @@ +"""Register a compiled colleague as a real FreeOS chat agent.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from octop.infra.utils.ulid import new_short_id +from octop.modules.org_os.lifecycle.store import ColleagueRecord, LifecycleStore +from octop.modules.org_os.runtime.memory import sync_colleague_memory +from octop.modules.org_os.runtime.routing import ColleagueRouter, RouteEntry + +logger = logging.getLogger(__name__) + + +def agents_registry_path(home: Path) -> Path: + return home / "org-agents" / "registry.json" + + +@dataclass +class SpawnedAgent: + agent_id: str + slug: str + name: str + tenant_id: str + workspace: str + system_prompt: str + skill_package_id: str = "" + skill_count: int = 0 + persisted_to_db: bool = False + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _agent_id_for(slug: str, existing: str = "") -> str: + if existing: + return existing + cleaned = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower()) + candidate = f"org-{cleaned}".strip("-") + if len(candidate) < 3: + candidate = f"org-{new_short_id(6).lower()}" + return candidate[:64] + + +def _system_prompt(workspace: Path, name: str) -> str: + soul = workspace / "SOUL.md" + if soul.is_file(): + return soul.read_text(encoding="utf-8") + return f"You are {name}, a FreeOS digital colleague. Follow workspace MEMORY.md." + + +def _write_file_registry(home: Path, spawned: SpawnedAgent) -> Path: + path = agents_registry_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + data: dict[str, Any] = {"agents": {}} + if path.is_file(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict) and isinstance(raw.get("agents"), dict): + data = raw + except json.JSONDecodeError: + pass + data["agents"][spawned.agent_id] = spawned.to_dict() + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + per_agent = path.parent / f"{spawned.agent_id}.json" + per_agent.write_text(json.dumps(spawned.to_dict(), indent=2) + "\n", encoding="utf-8") + return path + + +def _copy_skills_to_packages(home: Path, agent_id: str, workspace: Path) -> tuple[str, int]: + skills_root = workspace / "skills" + if not skills_root.is_dir(): + return "", 0 + package_id = (agent_id if agent_id.startswith("org-") else f"org-{agent_id}")[:64] + dest = home / "skill-packages" / package_id / "skills" + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()): + if not (skill_dir / "SKILL.md").is_file(): + continue + target = dest / skill_dir.name + target.mkdir(parents=True, exist_ok=True) + (target / "SKILL.md").write_bytes((skill_dir / "SKILL.md").read_bytes()) + count += 1 + # Also expose catalog org-* skills so the colleague can call the BFF. + org_skills = home / "org-skills" + if org_skills.is_dir(): + for skill_dir in sorted(path for path in org_skills.iterdir() if path.is_dir()): + if not (skill_dir / "SKILL.md").is_file(): + continue + target = dest / skill_dir.name + target.mkdir(parents=True, exist_ok=True) + (target / "SKILL.md").write_bytes((skill_dir / "SKILL.md").read_bytes()) + count += 1 + return package_id if count else "", count + + +def _persist_agent_row(home: Path, spawned: SpawnedAgent) -> bool: + """Insert/update the host agents table so the colleague appears in FreeOS chat.""" + try: + from octop.cli.support.db import open_cli_services + from octop.infra.agents.profile import dump_skill_package_ids + except Exception as exc: # pragma: no cover - import graph + logger.debug("agent DB helpers unavailable: %s", exc) + return False + config = { + "workspace_dir": spawned.workspace, + "org": { + "colleague_slug": spawned.slug, + "tenant_id": spawned.tenant_id, + "kind": "freeos.digital-colleague", + }, + "skills": {"extra_dirs": [str(Path(spawned.workspace) / "skills")]}, + } + try: + with open_cli_services(home) as services: + repo = services.agent_repo + existing = repo.get(spawned.agent_id) + skill_ids = ( + dump_skill_package_ids([spawned.skill_package_id]) + if spawned.skill_package_id + else None + ) + if existing is None: + repo.create( + agent_id=spawned.agent_id, + user_id=None, + name=spawned.name, + description=f"FreeOS digital colleague `{spawned.slug}`", + system_prompt=spawned.system_prompt, + config_json=json.dumps(config, ensure_ascii=False), + template_name="org-colleague", + skill_package_ids=skill_ids, + welcome_message=f"{spawned.name} is on duty. High-risk tools stay gated.", + ) + try: + repo.set_shared(spawned.agent_id, True) + except Exception: + logger.debug("set_shared skipped", exc_info=True) + else: + repo.update_config( + spawned.agent_id, + config_json=json.dumps(config, ensure_ascii=False), + system_prompt=spawned.system_prompt, + skill_package_ids=skill_ids, + ) + if spawned.skill_package_id and hasattr(services, "skill_package_repo"): + pkg_repo = services.skill_package_repo + if pkg_repo.get(spawned.skill_package_id) is None: + try: + pkg_repo.create( + id=spawned.skill_package_id, + name=f"{spawned.name} skills", + description=f"Skills compiled for colleague {spawned.slug}", + created_by="org-loop", + ) + except Exception: + logger.debug("skill package create skipped", exc_info=True) + except Exception: + logger.info( + "could not persist colleague into octop.db; file registry remains", exc_info=True + ) + return False + return True + + +def spawn_colleague_agent( + home: Path, + record: ColleagueRecord, + *, + agent_id: str = "", +) -> SpawnedAgent: + """Create a FreeOS agent from a compiled colleague workspace. + + Always writes the org-agents registry + routing table. When ``octop.db`` + can be opened, also inserts an ``agents`` row so the host chat list sees + the employee. + """ + workspace = Path(record.workspace) + package_id, skill_count = _copy_skills_to_packages(home, _agent_id_for(record.slug), workspace) + spawned = SpawnedAgent( + agent_id=_agent_id_for(record.slug, agent_id or record.agent_id), + slug=record.slug, + name=record.name or record.slug, + tenant_id=record.tenant_id, + workspace=str(workspace), + system_prompt=_system_prompt(workspace, record.name or record.slug), + skill_package_id=package_id, + skill_count=skill_count, + notes=["Registered for FreeOS chat. Governance middleware gates high-risk tools."], + ) + persisted = _persist_agent_row(home, spawned) + spawned.persisted_to_db = persisted + if persisted: + spawned.notes.append("Inserted into FreeOS agents table (octop.db).") + else: + spawned.notes.append("File registry written; host DB insert deferred until init.") + + _write_file_registry(home, spawned) + (workspace / "agent.json").write_text( + json.dumps(spawned.to_dict(), indent=2) + "\n", encoding="utf-8" + ) + record.agent_id = spawned.agent_id + LifecycleStore(home, record.tenant_id).upsert(record) + ColleagueRouter(home, record.tenant_id).upsert( + RouteEntry( + slug=record.slug, + agent_id=spawned.agent_id, + name=record.name, + tenant_id=record.tenant_id, + workspace=str(workspace), + lifecycle=record.lifecycle, + ) + ) + sync_colleague_memory(record, home) + return spawned + + +def list_spawned_agents(home: Path) -> list[SpawnedAgent]: + path = agents_registry_path(home) + if not path.is_file(): + return [] + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return [] + agents = raw.get("agents") if isinstance(raw, dict) else {} + if not isinstance(agents, dict): + return [] + rows: list[SpawnedAgent] = [] + for item in agents.values(): + if not isinstance(item, dict): + continue + rows.append( + SpawnedAgent( + agent_id=str(item.get("agent_id") or ""), + slug=str(item.get("slug") or ""), + name=str(item.get("name") or ""), + tenant_id=str(item.get("tenant_id") or ""), + workspace=str(item.get("workspace") or ""), + system_prompt=str(item.get("system_prompt") or ""), + skill_package_id=str(item.get("skill_package_id") or ""), + skill_count=int(item.get("skill_count") or 0), + persisted_to_db=bool(item.get("persisted_to_db")), + notes=list(item.get("notes") or []), + ) + ) + return rows diff --git a/src/octop/modules/org_os/service.py b/src/octop/modules/org_os/service.py index 1ee20e51..369c8549 100644 --- a/src/octop/modules/org_os/service.py +++ b/src/octop/modules/org_os/service.py @@ -201,13 +201,13 @@ def status(self) -> OrgModuleStatus: enabled = self.is_enabled() notes = [ "Host identity stays in FreeOS (JWT users under the platform home).", - "openXYOS is the control plane; FreeOS/Octop is the data plane. " - "Do not replace the Octop agent runtime with openXYOS chat.", + "openXYOS is the control plane; FreeOS is the data plane. " + "Do not replace the FreeOS agent runtime with openXYOS chat.", "Proxy forwards X-FreeOS-User* and X-FreeOS-Tenant-Id; sign in to " "the sidecar separately for mutating org routes.", "Plugin id org-os is seeded disabled; enable it here or via " "Admin → Plugins / `freeos org enable`.", - "Phase A: `freeos org governance enable` and `freeos org skills generate`.", + "Run the finished loop with `freeos org loop run`.", ] if not sidecar.reachable and enabled: notes.append( diff --git a/tests/e2e/test_org_growth_loop.py b/tests/e2e/test_org_growth_loop.py new file mode 100644 index 00000000..b4021681 --- /dev/null +++ b/tests/e2e/test_org_growth_loop.py @@ -0,0 +1,52 @@ +"""Scripted proof: blueprint → compile → recruit→active → publish → apply → import.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner +from tests.support.openxyos_harness import ControlPlaneState, start_control_plane + +from octop.cli.main import cli +from octop.modules.org_os.catalog import catalog_keys +from octop.modules.org_os.loop.run import run_growth_loop + + +def test_e2e_loop_command_and_artifacts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + + state = ControlPlaneState() + url, server = start_control_plane(state) + monkeypatch.setenv("FREEOS_HOME", str(tmp_path)) + monkeypatch.setenv("OPENXYOS_BASE_URL", url) + monkeypatch.setenv("FREEOS_ORG_TENANT_ID", "e2e") + try: + runner = CliRunner() + result = runner.invoke(cli, ["org", "loop", "run", "--tenant-id", "e2e", "--base-url", url]) + assert result.exit_code == 0, result.output + proof = json.loads(result.output) + assert proof["ok"] is True + assert proof["governance_blocked"] is True + assert "policy-analyst" in proof["employees"] + assert proof["lifecycle"]["active"] == "active" + assert proof["remote_applied"] is True + assert (tmp_path / "tenants" / "e2e" / "employees" / "policy-analyst" / "SOUL.md").is_file() + assert (tmp_path / "org-skills" / "org-employees" / "SKILL.md").is_file() + assert (tmp_path / "org-agents" / "registry.json").is_file() + assert ( + tmp_path / "asset-packs" / "latest" / "openxyos" / "org-employees.publish.json" + ).is_file() + assert (tmp_path / "openxyos-mirror" / "e2e" / "apply-receipt.json").is_file() + assert len(proof["skills"]) >= len(catalog_keys()) + assert state.employees + assert state.module_settings + finally: + server.shutdown() + server.server_close() + + +def test_e2e_library_path_matches_cli(tmp_path: Path) -> None: + proof = run_growth_loop(tmp_path, tenant_id="lib") + assert proof.ok + assert (tmp_path / "asset-packs" / "loop-proof.json").is_file() diff --git a/tests/fixtures/org-loop/agent-blueprint.v1.json b/tests/fixtures/org-loop/agent-blueprint.v1.json new file mode 100644 index 00000000..6d25cd48 --- /dev/null +++ b/tests/fixtures/org-loop/agent-blueprint.v1.json @@ -0,0 +1,28 @@ +{ + "schema": "openxyos.agent-blueprint.v1", + "name": "Policy Analyst", + "industry": "public-sector", + "positioning": "Reads org policy and drafts briefings; never sends mail or changes production.", + "capabilities": ["policy-research", "briefing"], + "experience": "Summarize policy diffs. Cite sources. Escalate high-risk asks.", + "references": [ + { + "name": "staff-handbook.md", + "type": ".md", + "scan": "clean", + "excerpt": "Staff must not share tenant data across workspaces." + } + ], + "governance": { + "high_risk_requires_human_review": true, + "external_write_disabled": true + }, + "lifecycle": "talent_market", + "job_duties": [ + { + "name": "morning-brief", + "schedule": "0 9 * * 1-5", + "prompt": "Draft the weekday policy briefing from knowledge/." + } + ] +} diff --git a/tests/fixtures/org-loop/ops-coordinator.blueprint.json b/tests/fixtures/org-loop/ops-coordinator.blueprint.json new file mode 100644 index 00000000..23574eb7 --- /dev/null +++ b/tests/fixtures/org-loop/ops-coordinator.blueprint.json @@ -0,0 +1,28 @@ +{ + "schema": "openxyos.agent-blueprint.v1", + "name": "Ops Coordinator", + "industry": "professional-services", + "positioning": "Coordinates org-module work and drafts runbooks; never deploys or pays.", + "capabilities": ["runbook-draft", "status-digest"], + "experience": "Keep a live ops digest. Escalate high-risk production or payment asks.", + "references": [ + { + "name": "ops-guardrails.md", + "type": ".md", + "scan": "clean", + "excerpt": "High-risk tools require a durable FreeOS governance approval." + } + ], + "governance": { + "high_risk_requires_human_review": true, + "external_write_disabled": true + }, + "lifecycle": "talent_market", + "job_duties": [ + { + "name": "ops-digest", + "schedule": "0 18 * * 1-5", + "prompt": "Summarize today's org-module activity from knowledge/." + } + ] +} diff --git a/tests/fixtures/org-loop/policies.json b/tests/fixtures/org-loop/policies.json new file mode 100644 index 00000000..b4ab68dc --- /dev/null +++ b/tests/fixtures/org-loop/policies.json @@ -0,0 +1,26 @@ +{ + "source": "freeos.demo-fixture", + "rules": [ + { + "category": "delete", + "action": "delete_employee", + "allow": false, + "reason": "Demo policy: deleting employees is denied until a human approves." + }, + { + "category": "pay", + "allow": false, + "reason": "Demo policy: payments stay default-deny." + }, + { + "category": "outbound", + "allow": false, + "reason": "Demo policy: outbound send is gated." + }, + { + "category": "prod", + "allow": false, + "reason": "Demo policy: production changes stay gated." + } + ] +} diff --git a/tests/support/openxyos_harness.py b/tests/support/openxyos_harness.py new file mode 100644 index 00000000..704b3753 --- /dev/null +++ b/tests/support/openxyos_harness.py @@ -0,0 +1,118 @@ +"""In-process openXYOS-shaped HTTP harness for apply+import roundtrip tests.""" + +from __future__ import annotations + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from typing import Any +from urllib.parse import urlparse + + +class ControlPlaneState: + def __init__(self) -> None: + self.employees: list[dict[str, Any]] = [] + self.talent: list[dict[str, Any]] = [] + self.plugins: list[dict[str, Any]] = [] + self.module_settings: dict[str, Any] = {} + self.permissions: list[dict[str, Any]] = [ + {"category": "delete", "allow": False, "reason": "harness deny"} + ] + self.posts: list[str] = [] + + +def start_control_plane(state: ControlPlaneState | None = None) -> tuple[str, ThreadingHTTPServer]: + store = state or ControlPlaneState() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: # noqa: A003 + return + + def _json(self, status: int, payload: Any) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self) -> Any: + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + return {} + raw = self.rfile.read(length) + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + return {} + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/health/livez": + self._json(200, {"ok": True}) + return + if path == "/api/employees": + self._json(200, {"success": True, "data": store.employees}) + return + if path == "/api/talent": + self._json(200, {"success": True, "data": store.talent}) + return + if path == "/api/plugins": + self._json(200, {"success": True, "data": store.plugins}) + return + if path == "/api/module-settings": + self._json(200, {"success": True, "data": store.module_settings}) + return + if path == "/api/governance/permissions": + self._json(200, {"success": True, "data": store.permissions}) + return + self._json(404, {"success": False, "error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + payload = self._read_json() + store.posts.append(path) + if path == "/api/employees": + if isinstance(payload, dict) and isinstance(payload.get("employees"), list): + store.employees.extend( + item for item in payload["employees"] if isinstance(item, dict) + ) + elif isinstance(payload, dict) and payload.get("name"): + store.employees.append(payload) + self._json(200, {"success": True, "data": {"id": len(store.employees)}}) + return + if path == "/api/talent": + if isinstance(payload, dict) and isinstance(payload.get("talent"), list): + store.talent.extend( + item for item in payload["talent"] if isinstance(item, dict) + ) + self._json(200, {"success": True, "data": {"ok": True}}) + return + if path == "/api/plugins": + if isinstance(payload, dict) and isinstance(payload.get("plugins"), list): + store.plugins.extend( + item for item in payload["plugins"] if isinstance(item, dict) + ) + self._json(200, {"success": True, "data": {"ok": True}}) + return + if path == "/api/governance/validate": + self._json(200, {"success": True, "data": {"allowed": False, "reason": "harness"}}) + return + self._json(404, {"success": False, "error": "not found"}) + + def do_PUT(self) -> None: # noqa: N802 + path = urlparse(self.path).path + payload = self._read_json() + store.posts.append(path) + if path == "/api/module-settings": + if isinstance(payload, dict): + store.module_settings = payload + self._json(200, {"success": True, "data": store.module_settings}) + return + self._json(404, {"success": False, "error": "not found"}) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address[:2] + return f"http://{host}:{port}", server diff --git a/tests/unit/test_colleague_spawn.py b/tests/unit/test_colleague_spawn.py new file mode 100644 index 00000000..0707637e --- /dev/null +++ b/tests/unit/test_colleague_spawn.py @@ -0,0 +1,41 @@ +"""Inbound compile/import registers a real FreeOS agent, not just files.""" + +from __future__ import annotations + +from pathlib import Path + +from octop.modules.org_os.compiler.compile import compile_blueprint +from octop.modules.org_os.lifecycle.store import LifecycleStore +from octop.modules.org_os.lifecycle.transitions import register_compiled +from octop.modules.org_os.runtime.routing import resolve_routed_agent +from octop.modules.org_os.runtime.spawn import spawn_colleague_agent + +_FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "org-loop" / "agent-blueprint.v1.json" + + +def test_spawn_writes_registry_and_agent_row(tmp_path: Path) -> None: + compiled = compile_blueprint(_FIXTURE, home=tmp_path, tenant_id="acme") + store = LifecycleStore(tmp_path, "acme") + record = register_compiled( + store, + slug=compiled.slug, + name="Policy Analyst", + workspace=compiled.workspace, + lifecycle="draft", + ) + spawned = spawn_colleague_agent(tmp_path, record) + assert spawned.agent_id == "org-policy-analyst" + assert spawned.persisted_to_db is True + assert (tmp_path / "org-agents" / "registry.json").is_file() + assert (compiled.workspace / "agent.json").is_file() + assert (tmp_path / "skill-packages" / spawned.skill_package_id / "skills").is_dir() + assert resolve_routed_agent(tmp_path, "policy-analyst") == spawned.agent_id + + from octop.cli.support.db import open_cli_services + + with open_cli_services(tmp_path) as services: + row = services.agent_repo.get(spawned.agent_id) + assert row is not None + assert row.name == "Policy Analyst" + assert row.system_prompt and "Policy Analyst" in row.system_prompt + assert compiled.workspace.as_posix() in (row.config_json or "") diff --git a/tests/unit/test_host_governance.py b/tests/unit/test_host_governance.py new file mode 100644 index 00000000..d49167b1 --- /dev/null +++ b/tests/unit/test_host_governance.py @@ -0,0 +1,42 @@ +"""Host tool path must actually block high-risk calls.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from octop.infra.agents.middleware.org_governance import ( + OrgGovernanceMiddleware, + host_gate_tool, +) +from octop.modules.org_os.governance.interceptor import GovernanceBlockedError +from octop.modules.org_os.service import OrgModuleService + + +def test_host_gate_blocks_high_risk_delete(tmp_path: Path) -> None: + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + service.set_governance_enabled(True, tenant_id="1") + with pytest.raises(GovernanceBlockedError) as exc: + host_gate_tool( + home=tmp_path, + tool_name="delete_employee", + args={"id": "9"}, + tenant_id="1", + enabled=True, + ) + assert exc.value.decision.execute is False + assert exc.value.decision.blocked is True + + +def test_host_gate_allows_low_risk(tmp_path: Path) -> None: + host_gate_tool(home=tmp_path, tool_name="list_employees", enabled=True) + + +def test_middleware_from_home_follows_org_enablement(tmp_path: Path) -> None: + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + service.set_enabled(True) + service.set_governance_enabled(True) + mw = OrgGovernanceMiddleware.from_home(tmp_path, config_path=tmp_path / "config.json") + assert mw.enabled is True + assert mw.home == tmp_path diff --git a/tests/unit/test_openxyos_apply.py b/tests/unit/test_openxyos_apply.py new file mode 100644 index 00000000..4323c7bc --- /dev/null +++ b/tests/unit/test_openxyos_apply.py @@ -0,0 +1,49 @@ +"""Asset-pack apply writes a durable mirror and talks HTTP when the plane is up.""" + +from __future__ import annotations + +import json +from pathlib import Path + +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 +from tests.support.openxyos_harness import ControlPlaneState, start_control_plane + +_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: + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + import_openxyos_assets( + tmp_path, tenant_id="acme", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False + ) + pack = publish_asset_pack(tmp_path, tenant_id="acme", out_dir=tmp_path / "pack") + result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="acme", base_url="") + assert result.mirrored is True + assert result.remote_applied is False + employees = json.loads((result.mirror_dir / "employees.json").read_text(encoding="utf-8")) + assert employees["employees"][0]["slug"] == "policy-analyst" + roundtrip = import_applied_surfaces(tmp_path, tenant_id="acme") + assert "employees" in roundtrip["local"] + + +def test_apply_import_roundtrip_against_harness(tmp_path: Path) -> None: + import_openxyos_assets( + tmp_path, tenant_id="acme", catalog=True, blueprint_path=_FIXTURE, spawn_agents=False + ) + pack = publish_asset_pack(tmp_path, tenant_id="acme", out_dir=tmp_path / "pack") + state = ControlPlaneState() + url, server = start_control_plane(state) + try: + result = apply_asset_pack(pack.directory, home=tmp_path, tenant_id="acme", base_url=url) + assert result.remote_applied is True + assert state.employees + assert state.plugins + imported = import_applied_surfaces(tmp_path, tenant_id="acme", base_url=url) + assert imported["remote"]["employees"] + assert imported["local"]["module_settings"]["enabled_by_default"] is False + finally: + server.shutdown() + server.server_close() diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py new file mode 100644 index 00000000..35629250 --- /dev/null +++ b/tests/unit/test_org_loop.py @@ -0,0 +1,57 @@ +"""Finished self-growth loop: fixtures prove produce → apply → import → agents.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from octop.modules.org_os.catalog import catalog_keys +from octop.modules.org_os.loop.run import run_growth_loop +from octop.modules.org_os.runtime.routing import ColleagueRouter, resolve_routed_agent +from octop.modules.org_os.runtime.spawn import list_spawned_agents +from tests.support.openxyos_harness import ControlPlaneState, start_control_plane + + +def test_growth_loop_with_fixtures(tmp_path: Path) -> None: + proof = run_growth_loop(tmp_path, tenant_id="loop") + assert proof.ok is True + assert proof.governance_blocked is True + assert "policy-analyst" in proof.employees + assert "ops-coordinator" in proof.employees + assert proof.lifecycle.get("active") == "active" + assert set(proof.skills) >= {f"org-{key}" for key in catalog_keys()} + assert proof.agents + workspace = tmp_path / "tenants" / "loop" / "employees" / "policy-analyst" + assert (workspace / "SOUL.md").is_file() + assert (workspace / "agent.json").is_file() + assert (tmp_path / "org-skills" / "org-governance" / "SKILL.md").is_file() + assert (tmp_path / "asset-packs" / "latest" / "manifest.json").is_file() + assert (tmp_path / "openxyos-mirror" / "loop" / "employees.json").is_file() + assert (tmp_path / "openxyos-mirror" / "loop" / "module-settings.json").is_file() + assert ( + tmp_path / "tenants" / "loop" / "org-knowledge" / "live" / "policy-analyst" / "MEMORY.md" + ).is_file() + routed = resolve_routed_agent(tmp_path, "Policy Analyst", tenant_id="loop") + assert routed.startswith("org-") + assert ColleagueRouter(tmp_path, "loop").get("policy-analyst") is not None + spawned = list_spawned_agents(tmp_path) + assert {item.slug for item in spawned} >= {"policy-analyst", "ops-coordinator"} + saved = json.loads((tmp_path / "asset-packs" / "loop-proof.json").read_text(encoding="utf-8")) + assert saved["ok"] is True + + +def test_growth_loop_applies_to_live_control_plane(tmp_path: Path) -> None: + state = ControlPlaneState() + url, server = start_control_plane(state) + try: + proof = run_growth_loop(tmp_path, tenant_id="live", sidecar_url=url) + assert proof.ok is True + assert proof.remote_applied is True + assert state.employees + assert state.module_settings.get("updates") + assert "/api/employees" in state.posts + assert "/api/module-settings" in state.posts + assert "employees" in proof.imported_roundtrip["remote_surfaces"] + finally: + server.shutdown() + server.server_close()