diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 1acd8086..ba5cab6b 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -30,7 +30,7 @@ on: default: true type: boolean release_tag: - description: "GitHub Release tag to attach to (e.g. v0.0.1). Used when not running on a v* ref." + description: "GitHub Release tag to attach to (e.g. v0.0.2). Used when not running on a v* ref." required: false default: "" type: string @@ -187,6 +187,9 @@ jobs: - name: Assemble green zip env: GREEN_HOST_PLAT: ${{ matrix.plat }} + # Default v0.0.2: Python host only. Set SHIP_OPENXYOS_RUNTIME=1 to + # embed Node/openXYOS (~500MB) in the portable zip + NSIS payload. + SKIP_ORG_SIDECAR: "1" run: | set -euo pipefail echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} RUNNER_ARCH=${RUNNER_ARCH:-} uname=$(uname -ms)" @@ -220,20 +223,11 @@ jobs: PYTHONNOUSERSITE=1 \ "$py" desktop/portable/verify_imports.py \ "${verify_args[@]}" - if [[ ! -f "${staging}/org-sidecar/openxyos/backend/server.ts" ]]; then - echo "org-sidecar missing under ${staging}" >&2 - exit 1 - fi - if [[ ! -f "${staging}/org-sidecar/openxyos/dist/index.html" ]]; then - echo "openXYOS frontend missing under ${staging}/org-sidecar/openxyos/dist" >&2 - exit 1 - fi - if [[ -f "${staging}/org-sidecar/node/node.exe" || -x "${staging}/org-sidecar/node/bin/node" ]]; then - echo "org-sidecar Node runtime present" - else - echo "bundled Node missing under ${staging}/org-sidecar/node" >&2 + if [[ -d "${staging}/org-sidecar" ]]; then + echo "default v0.0.2 portable zip must not embed org-sidecar (set SHIP_OPENXYOS_RUNTIME=1 for opt-in builds)" >&2 exit 1 fi + echo "slim portable zip: org-sidecar absent (in-host Organization)" # macOS: fail if any native extension linked Homebrew/MacPorts paths. if [[ "${{ matrix.plat }}" == darwin-* ]]; then REPO_ROOT="$PWD" # shellcheck source=desktop/portable/_common.sh @@ -268,11 +262,15 @@ jobs: - name: Package desktop app with bundled portable runtime working-directory: desktop/src + env: + # Slim NSIS: do not pass SHIP_OPENXYOS_RUNTIME=1 (no openxyos-runtime.zip). + SHIP_OPENXYOS_RUNTIME: "0" run: >- wails3 task package ARCH=${{ matrix.arch }} VERSION=${{ env.OCTOP_VERSION }} PORTABLE_ZIP=../portable/release/FreeOS-portable-${{ matrix.plat }}-${{ env.OCTOP_VERSION }}.zip + SHIP_OPENXYOS_RUNTIME=0 # archive: false — upload the prebuilt zip as-is. Default archive=true would # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip. diff --git a/.github/workflows/sync-main-to-develop.yml b/.github/workflows/sync-main-to-develop.yml index 57a768d6..520a70cf 100644 --- a/.github/workflows/sync-main-to-develop.yml +++ b/.github/workflows/sync-main-to-develop.yml @@ -14,7 +14,7 @@ on: workflow_dispatch: inputs: version: - description: "Release tag being synced, e.g. v0.0.1. Blank = read main's pyproject.toml." + description: "Release tag being synced, e.g. v0.0.2. Blank = read main's pyproject.toml." required: false type: string diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8a655e..7ee432ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 + - Windows 安装预配不再把 Node 工作目录选到残留的嵌套 `openxyos\\openxyos`:顶层已有 `backend-dist/server.js` 与 `dist/index.html` 时必须用 live 根。嵌套 cwd 会让 `node backend-dist/server.js` 立刻退出且 stdout/stderr 为空,安装空等 90s 后以退出码 12 失败。`start-sidecar.ps1` / 预配 / 桌面 Go / Python 启动路径统一按此选择 cwd;每次启动截断 `start.log`;Shell.Application 若未刷新日志则改走 explorer / Start-Process,Node fail-fast 不再伪装成 livez 超时。 - Windows 安装详情不再把 openXYOS 预配的 UTF-8 Node/PowerShell 控制台(`[Error] POST /api/auth`、`[seed]`、中文 Server/WebSocket 状态)按系统 ANSI/GBK 打成乱码。NSIS 只用 `nsExec::Exec` 等退出码,详情页只显示本地化步骤结果;完整日志写入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log` / `start.log`(UTF-8)。解压前停止并等待旧的 FreeOS openXYOS Node 退出;livez 通过即成功,不把启动期 auth 日志当失败。 - 组织页互生长通道两端都落到可选用状态:推送到 openXYOS 时员工/人才写成列表页能看到的 `active` / `internal` / `available`;从 openXYOS 回流时即使没有技能字段也会登记为同事,技能/插件/MCP 写入 `org-skills` / `org-plugins` / `org-mcps`,并把聊天专家挂到当前用户(修复 `user_id=NULL` 导致「专家」页看不见)。导入后跳到「专家」页,推送/循环后关闭管理抽屉并刷新预览。 @@ -97,11 +98,25 @@ - First launch on desktop / loopback opens a local guest session — no login wall. Register or sign in only when saving, exporting, or publishing to an account. - Login, splash, favicons, and desktop icons use the circular XYAI mark. Product version remains 0.0.1. +## [0.0.2] - 2026-09-18 + +当前 FreeOS 产品版本,与 0.0.1 边车时代 Windows 安装包区分。`1.0.0` 对本阶段过早;后续按 [semver](https://semver.org/spec/v2.0.0.html) 随产品成熟度递增。规范来源是 `pyproject.toml`(同步 `octop.__version__`、桌面 / NSIS / FnOS 回退值,以及 CI 产物名)。发布工作流需要标签时使用 `v0.0.2`;不重写已推送的历史 tag。 + +命令核对:`uv run freeos --version` 与 `uv run python -c "import octop; print(octop.__version__)"` 均应输出 `0.0.2`。 + +### 变更 + +- 桌面单一运行时:组织控制面能力在 FreeOS/Octop Python 宿主内运行(`org_os` / `/api/org-module/*` / 组织页原生工作台)。完整 openXYOS Node(3780)改为可选导出/同步,不再是安装或首屏的硬依赖。 +- 组织页默认不再嵌入 `127.0.0.1:3780`,也不再以 livez /「重启边车」挡住登录或首屏。互生长(导入 / 推送 / 循环)走宿主内镜像与同事/专家列表。 +- Windows 安装不再解压或启动 `openxyos-runtime`,也不再因 livez 失败中止安装。桌面启动默认不设置 `OPENXYOS_BASE_URL`,不拉起 Node;需要时设 `FREEOS_ORG_SIDECAR=1`。 +- `freeos org loop run` 不再 `ensure_sidecar` 或空等 3780。未配置边车时只写 `{FREEOS_HOME}/openxyos-mirror/` 与生命周期登记。 +- 产品版本从 `0.0.1` 升到 `0.0.2`,安装包 / NSIS DisplayVersion / `octop.__version__` 同步,便于与边车时代构建区分。后续 GitHub Release 标签为 `v0.0.2`。 +- 默认 Windows NSIS / 绿色便携包不再打入 `openxyos-runtime.zip` 或 `org-sidecar`(约 500MB Node 运行包)。`OPENXYOS_RUNTIME_ZIP_PRESENT` 默认关闭;仅 `SHIP_OPENXYOS_RUNTIME=1` 的构建才带边车。 + ## [0.0.1] - 2026-09-15 -当前 FreeOS 产品版本。`1.0.0` 对本阶段过早;后续按 [semver](https://semver.org/spec/v2.0.0.html) 随产品成熟度递增。规范来源是 `pyproject.toml`(同步 `octop.__version__`、桌面 / NSIS / FnOS 回退值,以及 CI 产物名)。发布工作流需要标签时使用 `v0.0.1`;不重写已推送的历史 tag。 +首个 FreeOS 产品版本(桌面默认拉起本机 openXYOS Node 边车)。后续版本见 [0.0.2]。规范来源是 `pyproject.toml`。历史发布标签为 `v0.0.1`;不重写已推送的历史 tag。 -命令核对:`uv run freeos --version` 与 `uv run python -c "import octop; print(octop.__version__)"` 均应输出 `0.0.1`。 ### 新增 diff --git a/README.md b/README.md index 2b81ef3a..2d11d728 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Python 3.12+ - Version + Version License: MIT + Apache-2.0

@@ -62,7 +62,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md). `FreeOS-desktop-windows-arm64-.exe`(ARM 电脑)。 2. 双击安装包。安装程序会放到「程序文件」并创建开始菜单和桌面快捷方式。 3. 打开 **FreeOS**。第一次启动会解压内置运行环境(可能要一两分钟),然后直接进入可用会话,无需先登录。 -4. 保存、导出或发布到账号时再注册或登录。组织控制台(openXYOS)已随安装包内置,无需再装 Node;侧栏 **Organization** 默认打开。 +4. 保存、导出或发布到账号时再注册或登录。侧栏 **Organization** 使用宿主内组织能力,不必再装 Node,也不依赖本机 `127.0.0.1:3780`。完整 openXYOS Node 栈是可选项(导出/同步/高级部署)。 数据目录默认是 `%USERPROFILE%\.freeos`(可用环境变量 `FREEOS_HOME` 改)。旧版 Octop 的 `~/.octop` 仍会被识别。卸载安装包会清空安装目录(默认为 `Program Files\FreeOS`)并删除快捷方式,但**不会**删除该用户数据目录;详见 [desktop/README.md](desktop/README.md#windows-uninstall)。 @@ -71,7 +71,7 @@ Operator detail: [docs/asset-loop.md](docs/asset-loop.md). ### Prerequisites - Python 3.12+ (the project uses [uv](https://docs.astral.sh/uv/)) -- Node.js 20.19+ only if you start the organization sidecar from source, or rebuild the dashboard. The Windows installer already bundles Node + openXYOS. +- Node.js 20.19+ only if you opt into the organization sidecar (`FREEOS_ORG_SIDECAR=1`) from source, or rebuild the dashboard. A fresh FreeOS install does not need Node. ### From this repository @@ -112,22 +112,25 @@ Full host suite: `uv run pytest` / `make test-fast` (needs the usual extra servi ## Enable the organization module -The host stays Python. openXYOS stays a TypeScript sidecar under `modules/openxyos/`. Toggle it from any of: +The host stays Python. Organization first paint is the native dashboard page +plus `/api/org-module/*` (catalog, ingest/import, growth loop, employees). +The TypeScript sidecar under `modules/openxyos/` is optional. | Surface | Action | |---|---| -| Dashboard | Sidebar → **Organization** → enable switch | -| CLI | `uv run freeos org enable` · `uv run freeos org status` | +| Dashboard | Sidebar → **Organization** (in-host workbench) | +| CLI | `uv run freeos org enable` · `uv run freeos org status` · `uv run freeos org loop run` | | Plugin | Admin → Plugins → **Organization OS** (`org-os`) | | API | `PATCH /api/org-module` with `{ "enabled": true }` | -Then start the sidecar (Node 20+): +Optional Node sidecar (export/sync/advanced deploy only): ```bash -bash scripts/run-org-sidecar.sh +FREEOS_ORG_SIDECAR=1 bash scripts/run-org-sidecar.sh ``` -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. +Default origin if opted in: `http://127.0.0.1:3780`. Organization and +`freeos org loop run` do **not** wait for `/api/health/livez`. **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. diff --git a/README_CN.md b/README_CN.md index ff9e225c..c6e95038 100644 --- a/README_CN.md +++ b/README_CN.md @@ -11,7 +11,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index 9c11d45a..59f181f8 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -49,8 +49,24 @@ export interface OrgControlPlane { approvals: number; } +export interface OrgColleague { + slug: string; + name: string; + lifecycle: string; + agent_id: string; + spawned: boolean; +} + +export interface OrgExpert { + agent_id: string; + slug: string; + name: string; +} + export interface OrgOverview { enabled: boolean; + runtime?: "in_host" | string; + sidecar_optional?: boolean; sidecar_reachable: boolean; sidecar_embed_ok?: boolean; sidecar_url: string; @@ -61,6 +77,14 @@ export interface OrgOverview { last_sync: string | null; freeos: OrgPlaneCounts; openxyos: OrgControlPlane; + colleagues?: OrgColleague[]; + experts?: OrgExpert[]; + org_surfaces?: { + employees: number; + talent: number; + skills: number; + plugins: number; + }; last_loop: Record | null; notes: string[]; catalog: OrgCapability[]; diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e852ab8b..0c2fc469 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -19,10 +19,25 @@ "statusBarTitle": "Organization", "on": "Enabled", "off": "Disabled", - "sidecarUp": "Local openXYOS connected", - "sidecarEmbedFailed": "Preview failed to load", - "sidecarDown": "Opening local openXYOS", - "sidecarOpening": "Opening local openXYOS", + "sidecarUp": "Optional openXYOS connected", + "sidecarEmbedFailed": "Optional preview failed to load", + "sidecarDown": "In-host organization", + "sidecarOpening": "In-host organization", + "inHostReady": "In-host organization ready", + "sidecarOptionalChip": "Node sidecar optional", + "advancedConsole": "Advanced: optional openXYOS", + "colleaguesTitle": "Colleagues and experts", + "colleaguesEmpty": "No colleagues yet. Import, produce, or run the growth loop.", + "orgSurfacesTitle": "Organization assets (in-host)", + "runtimeInHost": "FreeOS host", + "sidecarAdvancedHint": "The Node console on port 3780 is optional export/sync. Organization and the growth loop work without it.", + "packLocalDone": "Wrote the local organization mirror. Assets are selectable below and on Experts.", + "expertsHint": "Spawned colleagues are also listed on Experts.", + "surfaceEmployees": "Employees", + "surfaceTalent": "Talent", + "surfacePlugins": "Plugins", + "openExperts": "Open Experts", + "colleagueLifecycle": "Lifecycle", "lastSync": "Last sync", "restartSidecar": "Restart openXYOS frontend and backend", "restartSidecarCta": "Restart frontend and backend", @@ -104,7 +119,7 @@ "timelineRemote": "Pushed to control plane", "timelineGovernance": "High-risk tool blocked by governance", "emptyTitle": "First look at Organization", - "emptyBody": "FreeOS on the left owns colleagues, experts / assistants not yet in a department, skills, MCP, and tasks. openXYOS on the right owns department employees, blueprints, modules, and approvals. After Setup the local console opens on its own; you can also run the loop once to see a real cycle.", + "emptyBody": "FreeOS on the left owns colleagues, experts / assistants not yet in a department, skills, MCP, and tasks. Organization on the right owns department employees, blueprints, modules, and approvals — all in this host. Run the loop once to see a real cycle. The Node sidecar is optional.", "startSidecarTitle": "Opening local openXYOS", "startSidecarBody": "Organization opens the local test URL http://127.0.0.1:3780 directly.", "startSidecarAction": "Open local openXYOS", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index d25da8a3..66fc3aa6 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -19,10 +19,25 @@ "statusBarTitle": "组织", "on": "已启用", "off": "已关闭", - "sidecarUp": "本机 openXYOS 已连接", - "sidecarEmbedFailed": "预览页未能加载", - "sidecarDown": "正在打开本机 openXYOS", - "sidecarOpening": "正在打开本机 openXYOS", + "sidecarUp": "可选 openXYOS 已连接", + "sidecarEmbedFailed": "可选预览未能加载", + "sidecarDown": "宿主内组织已就绪", + "sidecarOpening": "宿主内组织已就绪", + "inHostReady": "宿主内组织已就绪", + "sidecarOptionalChip": "Node 边车可选", + "advancedConsole": "高级:可选 openXYOS", + "colleaguesTitle": "同事与专家", + "colleaguesEmpty": "还没有同事。请导入、从语料生产,或运行自增长循环。", + "orgSurfacesTitle": "组织资产(宿主内)", + "runtimeInHost": "FreeOS 宿主", + "sidecarAdvancedHint": "3780 上的 Node 控制台只用于导出/同步/高级部署。组织页和自增长循环不依赖它。", + "packLocalDone": "已写入本地组织镜像。资产可在下方和「专家」页选用。", + "expertsHint": "登记后的同事也会出现在「专家」页。", + "surfaceEmployees": "员工", + "surfaceTalent": "人才", + "surfacePlugins": "插件", + "openExperts": "打开专家页", + "colleagueLifecycle": "生命周期", "lastSync": "最近同步", "restartSidecar": "重启 openXYOS 前后端服务", "restartSidecarCta": "重启前后端服务", @@ -104,7 +119,7 @@ "timelineRemote": "已推送到控制面", "timelineGovernance": "高风险工具被治理拦截", "emptyTitle": "第一次打开组织", - "emptyBody": "左侧 FreeOS 负责同事、尚未入部门的专家 / 智能助手、技能、MCP 与任务;右侧 openXYOS 负责部门员工、蓝图、模块与审批。安装完成后会自动打开本机 openXYOS;也可直接运行组织循环看一次真实闭环。", + "emptyBody": "左侧 FreeOS 负责同事、尚未入部门的专家 / 智能助手、技能、MCP 与任务;右侧组织能力负责部门员工、蓝图、模块与审批——都在本宿主内。直接运行组织循环即可看到真实闭环。Node 边车是可选项。", "startSidecarTitle": "正在打开本机 openXYOS", "startSidecarBody": "组织界面直接访问本机测试网址 http://127.0.0.1:3780。", "startSidecarAction": "打开本机 openXYOS", diff --git a/dashboard/src/pages/Organization/Organization.module.less b/dashboard/src/pages/Organization/Organization.module.less index 7c1da28f..9bb3889e 100644 --- a/dashboard/src/pages/Organization/Organization.module.less +++ b/dashboard/src/pages/Organization/Organization.module.less @@ -1,13 +1,9 @@ /* * Organization page layout * ------------------------ - * The main view is a slim blue status bar + a full-area iframe. The iframe - * box is height-constrained to the remaining window (not stretched to match - * leftover chrome). That matters for openXYOS dialogs: `position:fixed` - * inside the iframe is relative to the iframe element's box. A tall iframe - * (old two-column layout) centered modals far below the visible FreeOS - * viewport. Keeping the iframe == the visible pane centers overlays both - * vertically and horizontally. + * The main view is a slim blue status bar + the in-host Organization + * workbench (catalog, colleagues, growth loop). The optional Node console + * is not required for first paint. */ .page { position: relative; @@ -19,6 +15,47 @@ gap: 0; } +.workbench { + flex: 1; + min-height: 0; + overflow: auto; + padding: 20px 24px 32px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.colleagueList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.colleagueRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-radius: 12px; + padding: 10px 14px; + background: var(--fn-bg-secondary); +} + +.colleagueName { + margin: 0; + font-size: 14px; + font-weight: 650; +} + +.colleagueMeta { + margin: 2px 0 0; + font-size: 12px; + color: var(--fn-text-secondary); +} + .statusBar { display: flex; align-items: flex-end; diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index c8398851..d8ce6bdc 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import OrganizationPage from "./index"; import { orgModuleApi, type OrgOverview } from "../../api/modules/orgModule"; import { message } from "../../utils/antdMessage"; -import { tryOpenInOrgBrowser } from "../../utils/orgBrowserHost"; function renderOrg() { return render( @@ -22,11 +21,13 @@ function renderOrg() { const overview: OrgOverview = { enabled: true, - sidecar_reachable: true, - sidecar_embed_ok: true, + runtime: "in_host", + sidecar_optional: true, + sidecar_reachable: false, + sidecar_embed_ok: false, sidecar_url: "http://127.0.0.1:3780", start_available: false, - install_ready: true, + install_ready: false, start_command: "", home: "/tmp", last_sync: null, @@ -41,14 +42,27 @@ const overview: OrgOverview = { tasks: 0, }, openxyos: { - reachable: true, + reachable: false, url: "http://127.0.0.1:3780", - detail: "ok", + detail: "sidecar optional; not configured", modules: 4, governance: true, tenant_id: "1", approvals: 0, }, + colleagues: [ + { + slug: "ops-coordinator", + name: "Ops", + lifecycle: "active", + agent_id: "org-ops-coordinator", + spawned: true, + }, + ], + experts: [ + { agent_id: "org-ops-coordinator", slug: "ops-coordinator", name: "Ops" }, + ], + org_surfaces: { employees: 1, talent: 0, skills: 2, plugins: 1 }, last_loop: null, notes: [], catalog: [ @@ -105,76 +119,64 @@ describe("OrganizationPage", () => { vi.mocked(orgModuleApi.startSidecar).mockReset(); vi.mocked(orgModuleApi.restartSidecar).mockReset(); vi.mocked(orgModuleApi.probeLivez).mockReset(); - vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({ - reachable: true, - url: "http://127.0.0.1:3780", - detail: "ok", - }); vi.mocked(orgModuleApi.assemble).mockReset(); vi.mocked(orgModuleApi.pack).mockReset(); + vi.mocked(orgModuleApi.runLoop).mockReset(); vi.mocked(message.success).mockReset(); vi.mocked(message.error).mockReset(); }); - it("embeds a multi-tab browser for local openXYOS and keeps extras behind drawers", async () => { + it("paints the in-host workbench without a 3780 iframe or livez gate", async () => { renderOrg(); - await waitFor(() => { - expect(screen.getByTestId("org-mini-browser")).toBeInTheDocument(); - }); - const frame = screen.getByTestId("org-browser-frame"); - expect(frame).toHaveAttribute( - "src", - expect.stringMatching(/^http:\/\/127\.0\.0\.1:3780\/\?freeos_embed=1/), - ); - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "http://127.0.0.1:3780/", - ); - expect(screen.getByTestId("org-browser-back")).toBeDisabled(); - expect(screen.getByTestId("org-browser-forward")).toBeDisabled(); - expect(screen.getByTestId("org-browser-reload")).toBeEnabled(); - const restart = screen.getByTestId("org-restart-sidecar"); - const lastSync = screen.getByTestId("org-last-sync"); - expect( - restart.compareDocumentPosition(lastSync) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect(screen.queryByText("organization.openSidecar")).toBeNull(); - expect(screen.getByTestId("org-download-source")).toHaveTextContent( - "organization.downloadSourceBar", - ); - - expect(screen.queryByText("organization.assembleAction")).toBeNull(); - expect(screen.queryByText("organization.startSidecarAction")).toBeNull(); - expect(screen.queryByTestId("org-download-source-drawer")).toBeNull(); - - fireEvent.click(screen.getByTestId("org-enable-module")); expect( - await screen.findByText("organization.catalogTitle"), + await screen.findByTestId("org-native-workbench"), ).toBeInTheDocument(); + expect(screen.getByTestId("org-runtime-chip")).toHaveTextContent( + "organization.inHostReady", + ); + expect(screen.getByText("organization.assembleAction")).toBeInTheDocument(); + expect(screen.getByText("organization.packAction")).toBeInTheDocument(); + expect(screen.getByText("organization.loopAction")).toBeInTheDocument(); + expect(screen.getByTestId("org-colleagues")).toHaveTextContent("Ops"); + expect(screen.getByTestId("org-surfaces")).toBeInTheDocument(); + expect(screen.queryByTestId("org-mini-browser")).toBeNull(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); + expect(screen.queryByTestId("org-restart-sidecar")).toBeNull(); + expect(screen.queryByTestId("org-restart-overlay")).toBeNull(); + expect(orgModuleApi.startSidecar).not.toHaveBeenCalled(); + expect(orgModuleApi.probeLivez).not.toHaveBeenCalled(); + }); - fireEvent.click(screen.getByTestId("org-manage-os")); - expect( - await screen.findByText("organization.assembleTitle"), - ).toBeInTheDocument(); - expect(screen.getByText("organization.loopTitle")).toBeInTheDocument(); - expect( - screen.getByTestId("org-download-source-drawer"), - ).toBeInTheDocument(); - expect(screen.queryByTestId("org-preview-blank")).toBeNull(); + it("does not auto-start or gate login when the optional sidecar is down", async () => { + vi.mocked(orgModuleApi.overview).mockResolvedValue({ + ...overview, + sidecar_reachable: false, + start_available: true, + install_ready: true, + }); + renderOrg(); + await screen.findByTestId("org-native-workbench"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(orgModuleApi.startSidecar).not.toHaveBeenCalled(); + expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); + expect(screen.getByText("organization.assembleAction")).toBeInTheDocument(); }); - it("downloads latest source from the status bar after picking a folder", async () => { + it("keeps sidecar start and source download behind Advanced", async () => { pickDesktopFolder.mockResolvedValue("D:\\源码\\openXYOS"); vi.mocked(orgModuleApi.downloadSource).mockResolvedValue({ path: "D:\\源码\\openXYOS\\openXYOS-main.zip", }); - renderOrg(); - fireEvent.click(await screen.findByTestId("org-download-source")); + await screen.findByTestId("org-native-workbench"); + expect(screen.queryByTestId("org-download-source")).toBeNull(); + expect(screen.queryByTestId("org-start-sidecar")).toBeNull(); + fireEvent.click(screen.getByTestId("org-advanced-console")); + fireEvent.click(await screen.findByTestId("org-download-source")); await waitFor(() => { - expect(pickDesktopFolder).toHaveBeenCalled(); expect(orgModuleApi.downloadSource).toHaveBeenCalledWith( "D:\\源码\\openXYOS", ); @@ -182,225 +184,37 @@ describe("OrganizationPage", () => { expect(message.success).toHaveBeenCalledWith( "organization.downloadSourceDone", ); - expect(screen.queryByText("organization.assembleTitle")).toBeNull(); - }); - - it("does not download when the folder picker is cancelled", async () => { - pickDesktopFolder.mockResolvedValue(null); - - renderOrg(); - fireEvent.click(await screen.findByTestId("org-download-source")); - - await waitFor(() => { - expect(pickDesktopFolder).toHaveBeenCalled(); - }); - expect(orgModuleApi.downloadSource).not.toHaveBeenCalled(); - expect(message.error).not.toHaveBeenCalled(); }); - it("opens trapped window.open / host URLs as extra tabs", async () => { - renderOrg(); - await screen.findByTestId("org-mini-browser"); - - expect( - tryOpenInOrgBrowser("https://github.com/XYAIStudio/openXYOS", "GitHub"), - ).toBe(true); - await waitFor(() => { - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "https://github.com/XYAIStudio/openXYOS", - ); - }); - expect( - screen - .getAllByTestId("org-browser-frame") - .some( - (node) => - node.getAttribute("src") === - "https://github.com/XYAIStudio/openXYOS", - ), - ).toBe(true); - - expect(window.open("https://example.com/docs", "_blank")).toBeNull(); - await waitFor(() => { - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "https://example.com/docs", - ); - }); - }); - - it("navigates the active tab from the address bar", async () => { - renderOrg(); - const address = await screen.findByTestId("org-address-bar"); - fireEvent.change(address, { - target: { value: "https://example.com/path" }, - }); - fireEvent.submit(address.closest("form") as HTMLFormElement); - await waitFor(() => { - expect(screen.getByTestId("org-browser-frame")).toHaveAttribute( - "src", - "https://example.com/path", - ); - }); - expect(screen.getByTestId("org-browser-back")).toBeEnabled(); - fireEvent.click(screen.getByTestId("org-browser-back")); - await waitFor(() => { - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "http://127.0.0.1:3780/", - ); - }); - fireEvent.click(screen.getByTestId("org-browser-reload")); - await waitFor(() => { - expect(screen.getByTestId("org-browser-frame")).toHaveAttribute( - "data-tab-id", - "org-home", - ); - }); - }); - - it("restarts openXYOS and returns the embed to the local home page", async () => { - vi.mocked(orgModuleApi.restartSidecar).mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - return { - started: true, - already: false, - reachable: true, - url: "http://127.0.0.1:3780", - command: "start-sidecar", - detail: "restarted openXYOS frontend and backend", - launcher: "", - }; - }); - renderOrg(); - const address = await screen.findByTestId("org-address-bar"); - fireEvent.change(address, { - target: { value: "https://example.com/docs" }, - }); - fireEvent.submit(address.closest("form") as HTMLFormElement); - fireEvent.click(screen.getByTestId("org-restart-sidecar")); - const overlay = await screen.findByTestId("org-restart-overlay"); - expect(overlay).toBeInTheDocument(); - expect(overlay).toHaveTextContent("organization.restartOverlayTitle"); - expect(screen.getAllByTestId("org-restart-step")).toHaveLength(5); - await waitFor(() => { - expect(orgModuleApi.restartSidecar).toHaveBeenCalled(); - }); - await waitFor(() => { - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "http://127.0.0.1:3780/", - ); - }); - expect(screen.queryByTestId("org-restart-overlay")).toBeNull(); - expect(message.success).toHaveBeenCalledWith( - "organization.restartSidecarDone", - ); - }); - - it("gates the local test page when livez is down and prompts restart", async () => { - vi.mocked(orgModuleApi.overview).mockResolvedValue({ - ...overview, - sidecar_reachable: false, - sidecar_embed_ok: false, - start_available: false, - install_ready: false, - }); - vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({ - reachable: false, - url: "http://127.0.0.1:3780", - detail: "sidecar unreachable", - }); - renderOrg(); - expect(await screen.findByTestId("org-sidecar-gate")).toBeInTheDocument(); - expect(screen.getByTestId("org-sidecar-gate")).toHaveTextContent( - "organization.previewNeedsRestart", - ); - expect(screen.getByTestId("org-browser-frame")).toHaveAttribute( - "src", - "about:blank", - ); - expect(screen.getByTestId("org-restart-sidecar")).toBeInTheDocument(); - vi.mocked(orgModuleApi.restartSidecar).mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 80)); - return { - started: true, - already: false, - reachable: true, - url: "http://127.0.0.1:3780", - command: "start-sidecar", - detail: "ok", - launcher: "", - }; - }); - fireEvent.click(screen.getByTestId("org-sidecar-gate-restart")); - expect( - await screen.findByTestId("org-restart-overlay"), - ).toBeInTheDocument(); - }); - - it("probes livez before opening a sidecar URL from the address bar", async () => { - vi.mocked(orgModuleApi.overview).mockResolvedValue({ - ...overview, + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { + vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, - start_available: false, - install_ready: false, - }); - vi.mocked(orgModuleApi.probeLivez).mockResolvedValue({ - reachable: false, - url: "http://127.0.0.1:3780", - detail: "sidecar unreachable", - }); - renderOrg(); - await screen.findByTestId("org-sidecar-gate"); - fireEvent.submit( - screen.getByTestId("org-address-bar").closest("form") as HTMLFormElement, - ); - await waitFor(() => { - expect(orgModuleApi.probeLivez).toHaveBeenCalled(); + employees: ["ops-coordinator"], + spawned: [ + { + agent_id: "org-ops-coordinator", + slug: "ops-coordinator", + name: "Ops", + }, + ], + imported: { skills: ["org-governance"], plugins: ["bridge"] }, + skills: ["org-governance"], + plugins: ["bridge"], + preview_path: "/experts", + notes: [ + "using in-host org storage and bundled openXYOS blueprint fixtures", + ], }); - expect(screen.getByTestId("org-sidecar-gate")).toBeInTheDocument(); - expect(screen.getByTestId("org-browser-frame")).toHaveAttribute( - "src", - "about:blank", - ); - }); - it("auto-starts the sidecar when Organization is opened offline", async () => { - vi.mocked(orgModuleApi.overview).mockResolvedValue({ - ...overview, - sidecar_reachable: false, - sidecar_embed_ok: false, - start_available: true, - }); - vi.mocked(orgModuleApi.startSidecar).mockResolvedValue({ - started: true, - already: false, - reachable: true, - url: "http://127.0.0.1:3780", - command: "", - detail: "ok", - launcher: "", - }); renderOrg(); - await waitFor(() => { - expect(orgModuleApi.startSidecar).toHaveBeenCalled(); - }); - }); + fireEvent.click(await screen.findByTestId("org-assemble")); - it("surfaces a blank-preview error when livez is up but embed origin fails", async () => { - vi.mocked(orgModuleApi.overview).mockResolvedValue({ - ...overview, - sidecar_embed_ok: false, - }); - renderOrg(); - expect(await screen.findByTestId("org-preview-blank")).toHaveTextContent( - "organization.previewBlank", - ); - expect( - screen.getByText("organization.sidecarEmbedFailed"), - ).toBeInTheDocument(); + expect(await screen.findByTestId("experts-page")).toBeInTheDocument(); + expect(orgModuleApi.assemble).toHaveBeenCalled(); + expect(message.success).toHaveBeenCalledWith("organization.assembleDone"); }); - it("shows pack landed counts and opens the employees preview", async () => { - vi.mocked(orgModuleApi.overview).mockResolvedValue(overview); + it("packs to the in-host mirror without opening a 3780 preview", async () => { vi.mocked(orgModuleApi.pack).mockResolvedValue({ pack: { directory: "/tmp/pack", @@ -413,62 +227,47 @@ describe("OrganizationPage", () => { applied: { pack_dir: "/tmp/pack", mirror_dir: "/tmp/mirror", - remote_applied: true, + remote_applied: false, mirrored: true, - notes: ["control plane accepted the FreeOS ingest"], - tenant_id: 2, - preview_path: "/employees", - landed: { - tenant_id: 2, - preview: "/employees", - landed: { - employees: { created: 2, updated: 0 }, - talent: { created: 2, updated: 0 }, - plugins: { created: 11, updated: 0 }, - skills: { created: 12, updated: 0 }, - mcp: { created: 1, updated: 0 }, - }, - }, + notes: ["OPENXYOS_BASE_URL unset; applied to local mirror only"], }, }); renderOrg(); - fireEvent.click(await screen.findByTestId("org-manage-os")); - fireEvent.click(screen.getByTestId("org-pack")); + fireEvent.click(await screen.findByTestId("org-pack")); - await waitFor(() => { - expect(screen.getByTestId("org-address-bar")).toHaveValue( - "http://127.0.0.1:3780/employees", - ); - }); - expect(screen.queryByText("organization.assembleTitle")).toBeNull(); + expect(await screen.findByTestId("org-last-receipt")).toHaveTextContent( + "organization.packLocalDone", + ); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); expect(message.success).toHaveBeenCalledWith("organization.packDone"); }); - it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { - vi.mocked(orgModuleApi.assemble).mockResolvedValue({ - sidecar_reachable: true, - employees: ["ops-coordinator"], - spawned: [ - { - agent_id: "org-ops-coordinator", - slug: "ops-coordinator", - name: "Ops", - }, - ], - imported: { skills: ["org-governance"], plugins: ["bridge"] }, - skills: ["org-governance"], - plugins: ["bridge"], - preview_path: "/experts", - notes: ["imported live control-plane export"], + it("runs the growth loop without requiring sidecar health", async () => { + vi.mocked(orgModuleApi.runLoop).mockResolvedValue({ + ok: true, + tenant_id: "1", + home: "/tmp", + skills: ["org-employees"], + employees: ["policy-analyst"], + agents: [{ slug: "policy-analyst" }], + lifecycle: { active: "active" }, + pack_dir: "/tmp/pack", + mirror_dir: "/tmp/mirror", + remote_applied: false, + imported_roundtrip: {}, + governance_blocked: true, + governance: {}, + notes: ["Loop produced colleagues"], }); renderOrg(); - fireEvent.click(await screen.findByTestId("org-manage-os")); - fireEvent.click(screen.getByTestId("org-assemble")); + fireEvent.click(await screen.findByTestId("org-loop")); - expect(await screen.findByTestId("experts-page")).toBeInTheDocument(); - expect(orgModuleApi.assemble).toHaveBeenCalled(); - expect(message.success).toHaveBeenCalledWith("organization.assembleDone"); + await waitFor(() => { + expect(orgModuleApi.runLoop).toHaveBeenCalled(); + }); + expect(message.success).toHaveBeenCalledWith("organization.loopOk"); + expect(screen.queryByTestId("org-sidecar-gate")).toBeNull(); }); }); diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index ea711ac1..e723abf8 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Drawer, Input, Space, Switch, Tag } from "antd"; import { @@ -7,7 +7,6 @@ import { Download, Package, Play, - RefreshCw, Settings2, Shield, Users, @@ -31,40 +30,10 @@ import { } from "../../utils/desktopFolder"; import { message } from "../../utils/antdMessage"; import { resolveOpenxyosSourceDest } from "./pickSourceDest"; -import { sidecarRecoverPhase, shouldShowPreviewBlank } from "./sidecarRecover"; -import { confirmSidecarLivez, sidecarPreviewGate } from "./sidecarLivez"; -import OrgMiniBrowser from "./OrgMiniBrowser"; -import OrgRestartOverlay from "./OrgRestartOverlay"; -import { - DEFAULT_ORG_URL, - closeOrgTab, - createHomeTab, - goBackOrgTab, - goForwardOrgTab, - isSidecarOriginUrl, - navigateOrgTab, - normalizeOrgUrl, - openOrgTab, - parseOrgNavigatedMessage, - parseOrgOpenTabMessage, - reloadOrgTab, - sidecarOriginOf, - tabTitleFromUrl, - type OrgBrowserTab, -} from "./orgBrowser"; -import { installOrgPageWindowTrap } from "./orgPageWindowTrap"; -import { registerOrgBrowserHost } from "../../utils/orgBrowserHost"; import styles from "./Organization.module.less"; -type ActionKey = - | "assemble" - | "pack" - | "loop" - | "sidecar" - | "restart" - | "produce" - | null; -type DrawerKey = "module" | "manage" | null; +type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null; +type DrawerKey = "module" | "advanced" | null; type LastReceipt = { kind: "assemble" | "pack" | "loop"; @@ -123,51 +92,6 @@ export default function OrganizationPage() { const [produceName, setProduceName] = useState(""); const [produceIma, setProduceIma] = useState(""); const [landed, setLanded] = useState | null>(null); - const autoStartRef = useRef(false); - const iframeRefs = useRef>({}); - const livezOkRef = useRef(false); - const [previewNonce, setPreviewNonce] = useState(""); - const [livezOk, setLivezOk] = useState(false); - const [autoStarting, setAutoStarting] = useState(false); - const [autoStartFailed, setAutoStartFailed] = useState(false); - const [restartFinished, setRestartFinished] = useState(false); - const [openBlocked, setOpenBlocked] = useState(false); - const homeTitle = t("organization.homeTabTitle"); - const [tabs, setTabs] = useState(() => [ - createHomeTab(DEFAULT_ORG_URL, homeTitle), - ]); - const [activeId, setActiveId] = useState("org-home"); - const [addressValue, setAddressValue] = useState(DEFAULT_ORG_URL); - - const showPreview = useCallback( - (path: string) => { - const dest = normalizeOrgUrl( - `${(overview?.sidecar_url || DEFAULT_ORG_URL).replace(/\/$/, "")}${ - path.startsWith("/") ? path : `/${path}` - }`, - ); - const origin = sidecarOriginOf( - (overview?.sidecar_url || DEFAULT_ORG_URL).replace(/\/$/, ""), - ); - if (isSidecarOriginUrl(dest, origin) && !livezOkRef.current) { - void confirmSidecarLivez({ - origin, - apiProbe: orgModuleApi.probeLivez, - }).then((ok) => { - setLivezOk(ok); - livezOkRef.current = ok; - if (!ok) setOpenBlocked(true); - }); - } - setTabs((current) => - navigateOrgTab(current, "org-home", dest, homeTitle), - ); - setActiveId("org-home"); - setAddressValue(dest); - setPreviewNonce(String(Date.now())); - }, - [homeTitle, overview?.sidecar_url], - ); const applyOverview = useCallback( async (quiet = false) => { @@ -175,9 +99,6 @@ export default function OrganizationPage() { try { const next = await orgModuleApi.overview(); setOverview(next); - setLivezOk(Boolean(next.sidecar_reachable)); - livezOkRef.current = Boolean(next.sidecar_reachable); - if (next.sidecar_reachable) setOpenBlocked(false); if (next.module_toggles) setModuleToggles(next.module_toggles); const proof = next.last_loop; if (proof && typeof proof === "object" && proof.landed) { @@ -198,43 +119,11 @@ export default function OrganizationPage() { [t], ); - const load = useCallback(async () => { - await applyOverview(false); - }, [applyOverview]); - - useEffect(() => { - void load(); - }, [load]); - useEffect(() => { - if (overview?.sidecar_reachable) return; - const id = window.setInterval(() => { - void applyOverview(true); - }, 4000); - return () => window.clearInterval(id); - }, [applyOverview, overview?.sidecar_reachable]); + void applyOverview(false); + }, [applyOverview]); - const sidecarUp = Boolean(overview?.sidecar_reachable) && livezOk; - const embedOk = overview?.sidecar_embed_ok; - const previewBlank = shouldShowPreviewBlank({ - sidecarUp, - embedOk, - }); - const recoverPhase = sidecarRecoverPhase({ - sidecarUp, - installReady: Boolean(overview?.install_ready), - startAvailable: Boolean(overview?.start_available), - autoStarting, - autoStartFailed, - }); - const previewGate = sidecarPreviewGate({ - livezOk: sidecarUp, - restarting: busy === "restart", - }); - const showRestartGate = - Boolean(overview) && - previewGate === "needsRestart" && - (openBlocked || recoverPhase === "needsRestart"); + const sidecarUp = Boolean(overview?.sidecar_reachable); const firstRun = useMemo(() => { if (!overview) return false; return ( @@ -261,15 +150,6 @@ export default function OrganizationPage() { } }; - const waitForSidecar = async () => { - for (let i = 0; i < 30; i += 1) { - const next = await applyOverview(true); - if (next?.sidecar_reachable) return next; - await new Promise((resolve) => window.setTimeout(resolve, 1000)); - } - return applyOverview(true); - }; - const runAction = async ( key: Exclude, fn: () => Promise, @@ -277,7 +157,7 @@ export default function OrganizationPage() { setBusy(key); try { await fn(); - if (key !== "sidecar" && key !== "restart") { + if (key !== "sidecar") { await applyOverview(false); } } catch (err) { @@ -291,46 +171,11 @@ export default function OrganizationPage() { const startSidecar = () => runAction("sidecar", async () => { - setAutoStarting(true); - setAutoStartFailed(false); - try { - const result = await orgModuleApi.startSidecar(); - setLastActionNotes([result.detail, result.command].filter(Boolean)); - const next = result.reachable - ? await applyOverview(true) - : await waitForSidecar(); - if (next?.sidecar_reachable) { - setAutoStartFailed(false); - return; - } - setAutoStartFailed(true); - } catch { - setAutoStartFailed(true); - } finally { - setAutoStarting(false); - } + const result = await orgModuleApi.startSidecar(); + setLastActionNotes([result.detail, result.command].filter(Boolean)); + await applyOverview(true); }); - const canSilentStart = Boolean( - overview?.start_available || overview?.install_ready, - ); - - useEffect(() => { - if (autoStartRef.current) return; - if (!overview) return; - const embedBroken = - overview.sidecar_reachable && overview.sidecar_embed_ok === false; - if (overview.sidecar_reachable && !embedBroken) return; - if (!embedBroken && !canSilentStart) return; - autoStartRef.current = true; - void startSidecar(); - }, [ - canSilentStart, - overview, - overview?.sidecar_reachable, - overview?.sidecar_embed_ok, - ]); - const toggleModule = async (key: string, enabled: boolean) => { const next = { ...moduleToggles, [key]: enabled }; setModuleToggles(next); @@ -448,9 +293,8 @@ export default function OrganizationPage() { skills: landedCount(result.applied.landed, "skills"), plugins: landedCount(result.applied.landed, "plugins"), }), - t("organization.packPreviewEmployees"), ] - : []; + : [t("organization.packLocalDone")]; setLastActionNotes([ ...result.pack.notes, ...result.applied.notes, @@ -462,9 +306,6 @@ export default function OrganizationPage() { }); setDrawer(null); await applyOverview(true); - if (result.applied.remote_applied) { - showPreview(result.applied.preview_path || "/employees"); - } message.success(t("organization.packDone")); }); @@ -481,25 +322,11 @@ export default function OrganizationPage() { `${t("organization.timelineRemote")}: ${flagLabel( Boolean(proof.remote_applied), )}`, - ...(proof.remote_applied - ? [ - t("organization.packLanded", { - tenant: landedTenant(proof.landed), - employees: landedCount(proof.landed, "employees"), - talent: landedCount(proof.landed, "talent"), - skills: landedCount(proof.landed, "skills"), - plugins: landedCount(proof.landed, "plugins"), - }), - t("organization.packPreviewEmployees"), - ] - : []), + t("organization.packLocalDone"), ], }); setDrawer(null); await applyOverview(true); - if (proof.remote_applied) { - showPreview("/employees"); - } message.success( proof.ok ? t("organization.loopOk") : t("organization.loopPartial"), ); @@ -514,8 +341,8 @@ export default function OrganizationPage() { ? t("organization.progressLoop") : busy === "produce" ? t("organization.progressProduce") - : busy === "restart" - ? t("organization.restartingSidecar") + : busy === "sidecar" + ? t("organization.autoStartingSidecar") : null; const lastLoop = (loopProof ?? overview?.last_loop) as OrgLoopProof | null; @@ -523,209 +350,8 @@ export default function OrganizationPage() { ? formatServerIsoDateTime(overview.last_sync, timeZone) : t("organization.neverSynced"); const catalog = overview?.catalog ?? []; - const disabledKeys = useMemo( - () => - catalog - .filter((row) => moduleToggles[row.key] === false) - .map((row) => row.key), - [catalog, moduleToggles], - ); - const localConsoleUrl = (overview?.sidecar_url || DEFAULT_ORG_URL).replace( - /\/$/, - "", - ); - const sidecarOrigin = sidecarOriginOf(localConsoleUrl); - - const confirmLivez = useCallback(async (): Promise => { - const origin = sidecarOriginOf( - (overview?.sidecar_url || DEFAULT_ORG_URL).replace(/\/$/, ""), - ); - const ok = await confirmSidecarLivez({ - origin, - apiProbe: orgModuleApi.probeLivez, - }); - setLivezOk(ok); - livezOkRef.current = ok; - if (ok) setOpenBlocked(false); - return ok; - }, [overview?.sidecar_url]); - - const openTab = useCallback( - (raw: string, title?: string, reuse = true) => { - const url = normalizeOrgUrl(raw); - const origin = sidecarOriginOf( - (overview?.sidecar_url || DEFAULT_ORG_URL).replace(/\/$/, ""), - ); - if (isSidecarOriginUrl(url, origin) && !livezOkRef.current) { - void confirmLivez().then((ok) => { - if (!ok) setOpenBlocked(true); - }); - } - const label = title || tabTitleFromUrl(url, homeTitle); - let nextActive = ""; - let nextAddress = url; - setTabs((current) => { - const next = openOrgTab(current, url, label, reuse); - nextActive = next.activeId; - nextAddress = - next.tabs.find((tab) => tab.id === next.activeId)?.url ?? url; - return next.tabs; - }); - if (nextActive) setActiveId(nextActive); - setAddressValue(nextAddress); - return true; - }, - [confirmLivez, homeTitle, overview?.sidecar_url], - ); - - const submitAddress = useCallback(() => { - const url = normalizeOrgUrl(addressValue, localConsoleUrl + "/"); - if (isSidecarOriginUrl(url, sidecarOrigin) && !livezOkRef.current) { - void confirmLivez().then((ok) => { - if (!ok) setOpenBlocked(true); - }); - } - setTabs((current) => - navigateOrgTab(current, activeId, url, tabTitleFromUrl(url, homeTitle)), - ); - setAddressValue(url); - }, [ - activeId, - addressValue, - confirmLivez, - homeTitle, - localConsoleUrl, - sidecarOrigin, - ]); - - const goHomeAfterRestart = useCallback(() => { - const home = normalizeOrgUrl(localConsoleUrl + "/"); - setTabs([createHomeTab(home, homeTitle)]); - setActiveId("org-home"); - setAddressValue(home); - setPreviewNonce(String(Date.now())); - }, [homeTitle, localConsoleUrl]); - - const onBrowserBack = useCallback(() => { - setTabs((current) => { - const next = goBackOrgTab(current, activeId, homeTitle); - const url = next.find((tab) => tab.id === activeId)?.url; - if (url) setAddressValue(url); - return next; - }); - }, [activeId, homeTitle]); - - const onBrowserForward = useCallback(() => { - setTabs((current) => { - const next = goForwardOrgTab(current, activeId, homeTitle); - const url = next.find((tab) => tab.id === activeId)?.url; - if (url) setAddressValue(url); - return next; - }); - }, [activeId, homeTitle]); - - const onBrowserReload = useCallback(() => { - const tab = tabs.find((row) => row.id === activeId); - if ( - tab && - isSidecarOriginUrl(tab.url, sidecarOrigin) && - !livezOkRef.current - ) { - void confirmLivez().then((ok) => { - if (!ok) setOpenBlocked(true); - }); - return; - } - setTabs((current) => reloadOrgTab(current, activeId)); - }, [activeId, confirmLivez, sidecarOrigin, tabs]); - - const restartSidecar = () => - runAction("restart", async () => { - setRestartFinished(false); - setOpenBlocked(false); - const result = await orgModuleApi.restartSidecar(); - setLastActionNotes([result.detail, result.command].filter(Boolean)); - const next = result.reachable - ? await applyOverview(true) - : await waitForSidecar(); - if (next?.sidecar_reachable) { - setRestartFinished(true); - await new Promise((resolve) => window.setTimeout(resolve, 600)); - goHomeAfterRestart(); - message.success(t("organization.restartSidecarDone")); - return; - } - message.error( - t("organization.restartSidecarFailed", { - detail: result.detail || t("organization.actionFailed"), - }), - ); - }); - - const pushTogglesToPreview = useCallback(() => { - for (const frame of Object.values(iframeRefs.current)) { - frame?.contentWindow?.postMessage( - { type: "freeos:module-toggles", disabled: disabledKeys }, - "*", - ); - } - }, [disabledKeys]); - - useEffect(() => { - pushTogglesToPreview(); - }, [pushTogglesToPreview]); - - useEffect(() => { - const home = normalizeOrgUrl(localConsoleUrl + "/"); - setTabs((current) => { - const existing = current.find((tab) => tab.id === "org-home"); - if (!existing || existing.url === home) return current; - if (existing.url !== DEFAULT_ORG_URL) return current; - return navigateOrgTab(current, "org-home", home, homeTitle); - }); - setAddressValue((current) => - current === DEFAULT_ORG_URL ? home : current, - ); - }, [homeTitle, localConsoleUrl]); - - useEffect(() => { - const onMessage = (event: MessageEvent) => { - const opened = parseOrgOpenTabMessage(event.data); - if (opened) { - openTab(opened.url, opened.title); - return; - } - const navigated = parseOrgNavigatedMessage(event.data); - if (!navigated) return; - setTabs((current) => - navigateOrgTab( - current, - activeId, - navigated.url, - navigated.title || tabTitleFromUrl(navigated.url, homeTitle), - false, - ), - ); - setAddressValue(navigated.url); - }; - window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); - }, [activeId, homeTitle, openTab]); - - useEffect(() => { - const stopHost = registerOrgBrowserHost(openTab); - const stopTrap = installOrgPageWindowTrap(); - return () => { - stopTrap(); - stopHost(); - }; - }, [openTab]); - - useEffect(() => { - if (sidecarUp) { - setPreviewNonce((current) => current || String(Date.now())); - } - }, [sidecarUp]); + const colleagues = overview?.colleagues ?? []; + const orgSurfaces = overview?.org_surfaces; return ( @@ -738,42 +364,19 @@ export default function OrganizationPage() { {overview?.enabled ? t("organization.on") : t("organization.off")} + + {t("organization.inHostReady")} + - {previewBlank - ? t("organization.sidecarEmbedFailed") - : sidecarUp + {sidecarUp ? t("organization.sidecarUp") - : t("organization.sidecarOpening")} + : t("organization.sidecarOptionalChip")} - {t("organization.lastSync")}: {lastSync}

-
- { - setActiveId(id); - const tab = tabs.find((row) => row.id === id); - if (tab) setAddressValue(tab.url); - }} - onCloseTab={(id) => { - let nextActive = activeId; - let nextAddress = addressValue; - setTabs((current) => { - const next = closeOrgTab(current, activeId, id); - nextActive = next.activeId; - nextAddress = - next.tabs.find((row) => row.id === next.activeId)?.url ?? - addressValue; - return next.tabs; - }); - setActiveId(nextActive); - setAddressValue(nextAddress); - }} - onNewTab={() => openTab(localConsoleUrl + "/", homeTitle, false)} - onBack={onBrowserBack} - onForward={onBrowserForward} - onReload={onBrowserReload} - onFrameLoad={pushTogglesToPreview} - /> - {(busy === "restart" || showRestartGate) && ( - void restartSidecar()} - /> - )} - - - setDrawer(null)} - width={480} - destroyOnHidden - > -
-
- - {t("organization.toggleLabel")} - void toggle(checked)} - /> -
-

{t("organization.glossary")}

-
-

- {t("organization.catalogTitle")} -

-

- {t("organization.catalogToggleHint")} -

-
- {catalog.map((row) => ( -
-

- {isZh ? row.label_zh : row.label}{" "} - - {row.locked - ? t("organization.locked") - : moduleToggles[row.key] === false - ? t("organization.catalogDisabled") - : t("organization.catalogEnabled")} - -

-

- {isZh ? row.description_zh : row.description} -

- {!row.locked && ( - - void toggleModule(row.key, checked) - } - /> - )} -
- ))} -
-
-
-
- - setDrawer(null)} - width={720} - destroyOnHidden - > -
+

{t("organization.heroStory")}

{t("organization.glossary")}

@@ -984,7 +475,9 @@ export default function OrganizationPage() {

{t("organization.controlPlane")}

-

openXYOS

+

+ {t("organization.runtimeInHost")} +

@@ -1009,9 +502,7 @@ export default function OrganizationPage() {
- {sidecarUp - ? t("organization.sidecarUp") - : t("organization.sidecarOpening")} + {t("organization.inHostReady")} {t("organization.metricHealth")} @@ -1029,6 +520,87 @@ export default function OrganizationPage() { +
+

+ {t("organization.colleaguesTitle")} +

+

+ {t("organization.expertsHint")} +

+ {colleagues.length ? ( +
    + {colleagues.map((row) => ( +
  • +
    +

    {row.name}

    +

    + {row.slug} · {t("organization.colleagueLifecycle")}:{" "} + {row.lifecycle} + {row.agent_id ? ` · ${row.agent_id}` : ""} +

    +
    + + {row.spawned + ? t("organization.catalogEnabled") + : t("organization.catalogDisabled")} + +
  • + ))} +
+ ) : ( +

+ {t("organization.colleaguesEmpty")} +

+ )} + +
+ +
+

+ {t("organization.orgSurfacesTitle")} +

+
+
+ + {metric(orgSurfaces?.employees)} + + + {t("organization.surfaceEmployees")} + +
+
+ + {metric(orgSurfaces?.talent)} + + + {t("organization.surfaceTalent")} + +
+
+ + {metric(orgSurfaces?.skills)} + + + {t("organization.metricSkills")} + +
+
+ + {metric(orgSurfaces?.plugins)} + + + {t("organization.surfacePlugins")} + +
+
+
+ {progressLabel && (

{progressLabel}

@@ -1169,45 +741,115 @@ export default function OrganizationPage() { )}
-
-

- {t("organization.landedTitle")} -

- {landed ? ( + {landed ? ( +
+

+ {t("organization.landedTitle")} +

                 {JSON.stringify(landed, null, 2)}
               
- ) : ( -

{t("organization.landedEmpty")}

- )} +
+ ) : null} + +
+

+ {t("organization.catalogTitle")} +

+

+ {t("organization.catalogToggleHint")} +

+
+ {catalog.map((row) => ( +
+

+ {isZh ? row.label_zh : row.label}{" "} + + {row.locked + ? t("organization.locked") + : moduleToggles[row.key] === false + ? t("organization.catalogDisabled") + : t("organization.catalogEnabled")} + +

+

+ {isZh ? row.description_zh : row.description} +

+ {!row.locked && ( + + void toggleModule(row.key, checked) + } + /> + )} +
+ ))} +
+
+ - {(sidecarUp || overview?.enabled) && ( -
-

- {t("organization.downloadSource")} -

-

- {t("organization.downloadSourceHint")} -

- - {!canPickDesktopFolder() && ( - setSourceDest(event.target.value)} - placeholder={t("organization.downloadSourceDest")} - style={{ minWidth: 280 }} - /> - )} - - -
+ setDrawer(null)} + width={480} + destroyOnHidden + > +
+
+ + {t("organization.toggleLabel")} + void toggle(checked)} + /> +
+

{t("organization.glossary")}

+
+
+ + setDrawer(null)} + width={560} + destroyOnHidden + > +
+

+ {t("organization.sidecarAdvancedHint")} +

+

+ {overview?.sidecar_url || "http://127.0.0.1:3780"} +

+ + + + + {!canPickDesktopFolder() && ( + setSourceDest(event.target.value)} + placeholder={t("organization.downloadSourceDest")} + /> )}
diff --git a/desktop/README.md b/desktop/README.md index 74118968..4828c9ba 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -1,8 +1,8 @@ # FreeOS desktop (Wails v3 + green portable) Windows end-user product: **download the NSIS `.exe` → install → open FreeOS** -(Octop host shell + bundled openXYOS organization console). This is **not** -`src/octop/infra/desktop` (remote desktop streaming). +(Octop host shell + in-host Organization). The full openXYOS Node stack is +**optional**. This is **not** `src/octop/infra/desktop` (remote desktop streaming). | Path | Role | |------|------| @@ -21,32 +21,17 @@ Same precedence as the FreeOS CLI/server: 5. new installs → `~/.freeos` - Green runtime extract → `{home}/portable/` -- Organization sidecar data → `{home}/org-os/` -- Local openXYOS workdir (FE+BE) → `%LOCALAPPDATA%\FreeOS\openxyos` on Windows, - else `{home}/openxyos`. During Setup the FreeOS installer `nsExec`s a - shipped **provisioner subprocess** (`provision-openxyos.ps1`) that expands - `openxyos-runtime.zip` with `tar.exe` into that live workdir (and keeps - `$INSTDIR\openxyos` as a sealed copy; the `openxyos-runtime` zip and folder - are deleted after a successful provision), starts FE/BE - at medium integrity (Node inherits `CORS_ORIGIN` / `NODE_ENV`; stdout/stderr - go to UTF-8 `start.log`), and writes `.install-ready` only after - `http://127.0.0.1:3780/api/health/livez` is healthy. The Setup detail list - shows localized step results only (`nsExec::Exec`, not `ExecToLog`); full - PowerShell/Node console stays in `%LOCALAPPDATA%\FreeOS\openxyos\provision.log` - and `start.log` so UTF-8 Chinese is not misread as GBK. Before extract it - stops only FreeOS openXYOS Node (`start.pid` / live and `$INSTDIR\openxyos` - paths) and waits until that owned process is gone. After extract+heal, Node - starts with cwd at the live root when `backend-dist/server.js` and - `dist/index.html` exist there (a leftover nested `openxyos\openxyos` tree - must not win — that cwd exits immediately with an empty console). It unpacks into a LocalAppData - temp dir, logs tar stderr to `provision.log`, and treats a non-zero tar as - success when the layout heals. Transient `[Error] POST /api/auth` / `[seed]` - lines in `start.log` are not a provision failure when livez is healthy. If - Node dies before livez, the provisioner exits 10 with a `start.log` excerpt - in `provision.log` instead of waiting out a livez timeout. A failed - subprocess is a failed install step (retry the provisioner). There is no - Windows logon autostart: when FreeOS starts later, it brings local - openXYOS up with it. Organization embeds `http://127.0.0.1:3780` directly. +- Organization data (in-host) → `{home}/tenants/`, `{home}/openxyos-mirror/`, `{home}/org-skills/` +- Optional openXYOS Node sidecar data → `{home}/org-os/` +- Optional local openXYOS workdir (FE+BE) → `%LOCALAPPDATA%\FreeOS\openxyos` on Windows, + else `{home}/openxyos`. **Setup does not extract or start this stack.** + Default v0.0.2 installers do **not** copy `openxyos-runtime.zip`. + Opt-in builds (`SHIP_OPENXYOS_RUNTIME=1`) may add the zip plus + `provision-openxyos.ps1` / `start-sidecar.ps1` for later + `FREEOS_ORG_SIDECAR=1` use. Missing zip, tar.exe, or + `http://127.0.0.1:3780/api/health/livez` must not fail install. + There is no Windows logon autostart. Organization first paint is the + FreeOS-native page (`/api/org-module/*`), not an iframe of 3780. - Shell prefs → `{home}/desktop-settings.json` ## Windows install finish @@ -58,38 +43,21 @@ unelevated explorer token so the first run does not stamp `%USERPROFILE%\.freeos as High integrity. Uncheck to skip. Chinese installer strings are compiled with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. -The install log is no longer only `FreeOS.exe` + shortcuts. A healthy package -also copies `openxyos-runtime.zip` plus the provisioner (`provision-openxyos.ps1` -/ `.cmd`, `start-sidecar.ps1` / `.cmd`). Setup **nsExecs that provisioner as -a child process** (`nsExec::Exec`, stdout discarded) and waits for exit 0. -The child extracts with Windows -`tar.exe` (quoted paths, so `Program Files` works) into a LocalAppData -temp dir, then heals that tree into `%LOCALAPPDATA%\FreeOS\openxyos` after -stopping only the FreeOS openXYOS Node that would lock those files and -waiting until it is gone. tar -stdout/stderr go to UTF-8 `provision.log`, not the NSIS detail list. A non-zero tar is not exit 3 when the -layout is complete after heal. It then starts Node at medium integrity -(IShellDispatch2 — never High-IL Node from the elevated installer), waits -for livez, and writes `.install-ready` only when healthy. `$INSTDIR\openxyos` -is the sealed install copy. After success the provisioner removes -`$INSTDIR\openxyos-runtime.zip` (and similarly named archives) plus the -`$INSTDIR\openxyos-runtime` folder, and a sibling `openxyos-runtime` under -`%LOCALAPPDATA%\FreeOS` if one was staged there. A failed provision leaves -those artifacts for debugging. A README-only tree or a -failed health check is a provision failure, not a skipped success. Setup does -**not** register HKCU Run or a logon scheduled task. - -Why LocalAppData, written by an elevated installer: `$INSTDIR` is typically -`C:\Program Files\FreeOS`, which a later unelevated `FreeOS.exe` cannot -mutate. The app’s live root is `%LOCALAPPDATA%\FreeOS\openxyos`. Setup -reads the installing user’s `LOCALAPPDATA` environment variable (not NSIS -`$LOCALAPPDATA` after `SetShellVarContext all`, which is ProgramData) and -writes the complete FE+BE there so first launch does not pay a copy/unpack -cost. Organization then embeds `http://127.0.0.1:3780` directly. -Downloading the latest openXYOS source uses the same -native folder picker as the project workdir (any drive), not a typed path only. - -Override the workdir with `FREEOS_OPENXYOS_HOME`. +A healthy default package is **FreeOS.exe + the Python host** — no +`openxyos-runtime.zip` (~500MB Node payload). Pass +`SHIP_OPENXYOS_RUNTIME=1` to `wails3 task package` (and when assembling +the green zip) to embed the zip plus `provision-openxyos.ps1` / +`start-sidecar.ps1` for advanced Node export/sync. Setup **does not** +`nsExec` the provisioner, extract with `tar.exe`, start Node, or wait +for livez. A README-only or missing runtime tree is not an install +failure. Setup does **not** register HKCU Run or a logon scheduled task. + +Organization uses the in-host `/api/org-module/*` surfaces. Downloading the +latest openXYOS source (advanced) uses the same native folder picker as the +project workdir (any drive), not a typed path only. + +Override an optional sidecar workdir with `FREEOS_OPENXYOS_HOME`. Opt in with +`FREEOS_ORG_SIDECAR=1`. ## Windows uninstall @@ -97,9 +65,9 @@ The NSIS uninstaller (Settings → Apps, or `uninstall.exe` in the install folder) removes program-owned files. If FreeOS (or its host / org-sidecar) is still running, it **asks** before continuing (Chinese / English). Cancel aborts uninstall and leaves processes running. Confirm closes them -(graceful, then force) and then wipes the install directory. Version stays -`0.0.1`; rebuilds replace the existing GitHub Release `v0.0.1` assets -rather than cutting a new tag. +(graceful, then force) and then wipes the install directory. Product +version is `0.0.2`; ship artifacts as `v0.0.2` rather than overwriting +the sidecar-era `v0.0.1` Windows builds. **Removes** @@ -130,19 +98,14 @@ would treat that tree as program files (unless it is named `User Data` / On first open the shell: -1. Extracts the bundled portable **Python host** (the openXYOS FE+BE tree - was already written to `%LOCALAPPDATA%\FreeOS\openxyos` during Setup). -2. If that live workdir is already complete, skips any copy/unpack. A - copy from `$INSTDIR` / `portable/org-sidecar` is heal-only (other - Windows users, or a tree that Setup never populated). -3. If `http://127.0.0.1:3780/api/health/livez` is already up from Setup, - attaches to it. Otherwise FreeOS starts local openXYOS from the live - workdir and waits until livez responds. There is no logon autostart. -4. Starts the FreeOS host with `FREEOS_HOME`, `FREEOS_ORG_ENABLE=1`, and - `FREEOS_OPENXYOS_HOME`. -5. Opens the desktop window on the host UI with a local guest session (no - login wall). Chat and **Organization** are both available — no separate - Node install. Register or sign in later when a save needs an account. +1. Extracts the bundled portable **Python host**. +2. Does **not** extract or start `openxyos-runtime` unless `FREEOS_ORG_SIDECAR=1`. +3. Starts the FreeOS host with `FREEOS_HOME` and `FREEOS_ORG_ENABLE=1`. + It does not set `OPENXYOS_BASE_URL` / `FREEOS_ORG_SIDECAR_URL` by default. +4. Opens the desktop window on the host UI with a local guest session (no + login wall). Chat and **Organization** are both available in-host — no + Node process on 3780 is required. Register or sign in later when a save + needs an account. ## Build green zip @@ -152,7 +115,9 @@ From repo root (needs Node 20+ so openXYOS can be built into the zip): make -f desktop/portable/Makefile green ``` -Skip the sidecar only when debugging the Python runtime: `SKIP_ORG_SIDECAR=1`. +The Node sidecar is optional. Package it with the green zip when you want +advanced export/sync; skip it with `SKIP_ORG_SIDECAR=1`. Desktop/runtime +never requires it for Organization. CI: `.github/workflows/octop-desktop.yml` (**name:** FreeOS Desktop Package) builds native platform/arch variants. `v*` tags and `workflow_dispatch` diff --git a/desktop/package-release.sh b/desktop/package-release.sh index 9a856b5b..66afe8ff 100755 --- a/desktop/package-release.sh +++ b/desktop/package-release.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build one native FreeOS desktop release: Dashboard → portable runtime -# (including bundled Node + openXYOS) → Wails package. +# (Python host; Node/openXYOS only if SHIP_OPENXYOS_RUNTIME=1) → Wails package. # Run once per native platform; the GitHub Actions matrix runs all six variants. set -euo pipefail diff --git a/desktop/portable/README.md b/desktop/portable/README.md index 1bd2142c..677b0612 100644 --- a/desktop/portable/README.md +++ b/desktop/portable/README.md @@ -1,6 +1,6 @@ # FreeOS 绿色便携包(多平台) -解压即用:内置便携 CPython + FreeOS 宿主 + Node/openXYOS 组织边车,通过 `start.sh` / `start.bat` 启动。 +解压即用:内置便携 CPython + FreeOS 宿主(组织能力在宿主内)。Node/openXYOS 边车可选(`FREEOS_ORG_SIDECAR=1`),通过 `start.sh` / `start.bat` 启动。 **不依赖**系统 Python / Node。单独解压绿包时用浏览器打开 Dashboard;完整「安装即用」产品是 Wails NSIS 安装包。 首启走正常 setup wizard。 @@ -35,7 +35,7 @@ make -f desktop/portable/Makefile green FreeOS-/ runtime/ # python-build-standalone packages/ # FreeOS 宿主 + 依赖(site-packages,可搬迁) - org-sidecar/ # 内置 Node + 已构建的 openXYOS + org-sidecar/ # 可选;默认不打。SHIP_OPENXYOS_RUNTIME=1 才带 Node + openXYOS launch.py # 启动引导(site.addsitedir / Windows pywin32) start.sh # macOS / Linux start.bat # Windows diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index c6e64a4e..8b592563 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -14,7 +14,7 @@ # FreeOS-/ # runtime/ portable CPython # packages/ site-packages (uv --target, relocatable) -# org-sidecar/ bundled Node + openXYOS (organization console) +# org-sidecar/ optional Node + openXYOS (only if SHIP_OPENXYOS_RUNTIME=1) # start.sh / start.bat # README.txt # @@ -241,9 +241,17 @@ assemble_one() { cp "${TEMPLATES}/README.txt" "${staging}/README.txt" chmod +x "${staging}/start.sh" - echo "[package] ${plat}: bundling openXYOS sidecar" >&2 + # Default v0.0.2 portable zip is the Python host only. The Node/openXYOS + # sidecar (~500MB) is opt-in: SHIP_OPENXYOS_RUNTIME=1 or SKIP_ORG_SIDECAR=0. + if [[ "${SHIP_OPENXYOS_RUNTIME:-0}" == "1" ]]; then + SKIP_ORG_SIDECAR=0 + elif [[ -z "${SKIP_ORG_SIDECAR:-}" ]]; then + SKIP_ORG_SIDECAR=1 + fi + export SKIP_ORG_SIDECAR + echo "[package] ${plat}: org sidecar (SKIP_ORG_SIDECAR=${SKIP_ORG_SIDECAR})" >&2 bash "${REPO_ROOT}/desktop/portable/bundle-org-sidecar.sh" "$plat" "$staging" - if [[ "${SKIP_ORG_SIDECAR:-0}" != "1" ]]; then + if [[ "${SKIP_ORG_SIDECAR}" != "1" ]]; then if [[ ! -f "${staging}/org-sidecar/openxyos/dist/index.html" ]]; then echo "[package] ERROR: openXYOS frontend missing under ${staging}/org-sidecar/openxyos/dist" >&2 exit 1 diff --git a/desktop/portable/templates/README.txt b/desktop/portable/templates/README.txt index 536eb4f7..7728ccee 100644 --- a/desktop/portable/templates/README.txt +++ b/desktop/portable/templates/README.txt @@ -1,9 +1,9 @@ FreeOS green portable package ============================= -Extract this zip anywhere. It includes a portable CPython runtime, FreeOS -(Octop-compatible host), and a bundled Node + openXYOS organization sidecar. -No system Python or Node install is required. +Extract this zip anywhere. It includes a portable CPython runtime and the +FreeOS host (in-host Organization). No system Python is required. The +Node/openXYOS sidecar is optional and is not in the default v0.0.2 zip. Start ----- @@ -15,14 +15,15 @@ Defaults: http://127.0.0.1:8088 data dir = ./data (FREEOS_HOME) ./start.sh --home /path/to/data --host 127.0.0.1 --port 8088 First launch follows the normal FreeOS setup wizard (create admin password). -The organization module is enabled automatically; the sidecar listens on -http://127.0.0.1:3780 and the dashboard /organization page embeds it. +Organization uses the in-host /api/org-module surfaces. A Node process on +3780 is not required. Set FREEOS_ORG_SIDECAR=1 only for advanced export/sync +when a sidecar tree is present. Layout ------ runtime/ portable CPython packages/ FreeOS host + locked dependencies (site-packages) - org-sidecar/ bundled Node + built openXYOS + org-sidecar/ optional Node + openXYOS (SHIP_OPENXYOS_RUNTIME=1 builds only) launch.py entry bootstrap (loads packages/ + Windows pywin32 DLLs) start.sh / start.bat README.txt diff --git a/desktop/portable/templates/start.bat b/desktop/portable/templates/start.bat index 1c6f65e0..4a992c8a 100644 --- a/desktop/portable/templates/start.bat +++ b/desktop/portable/templates/start.bat @@ -69,8 +69,6 @@ exit /b 0 :run if not defined FREEOS_ORG_ENABLE set "FREEOS_ORG_ENABLE=1" -if not defined FREEOS_ORG_SIDECAR_URL set "FREEOS_ORG_SIDECAR_URL=http://127.0.0.1:3780" -if not defined OPENXYOS_BASE_URL set "OPENXYOS_BASE_URL=%FREEOS_ORG_SIDECAR_URL%" if not exist "%FREEOS_HOME%" mkdir "%FREEOS_HOME%" set "PY=%ROOT%\runtime\python.exe" @@ -90,10 +88,20 @@ set "PYTHONUTF8=1" set "PYTHONIOENCODING=utf-8" set "PYTHONPATH=" +if /I "%FREEOS_ORG_SIDECAR%"=="1" goto startSidecar +if /I "%FREEOS_ORG_SIDECAR%"=="true" goto startSidecar +if /I "%FREEOS_ORG_SIDECAR%"=="yes" goto startSidecar +if /I "%FREEOS_ORG_SIDECAR%"=="on" goto startSidecar +echo [freeos] organization is in-host (set FREEOS_ORG_SIDECAR=1 for the Node sidecar) +goto afterSidecar +:startSidecar +if not defined FREEOS_ORG_SIDECAR_URL set "FREEOS_ORG_SIDECAR_URL=http://127.0.0.1:3780" +if not defined OPENXYOS_BASE_URL set "OPENXYOS_BASE_URL=%FREEOS_ORG_SIDECAR_URL%" if exist "%ROOT%\org-sidecar\start-sidecar.bat" ( - echo [freeos] organization sidecar → %FREEOS_ORG_SIDECAR_URL% + echo [freeos] optional organization sidecar → %FREEOS_ORG_SIDECAR_URL% start "" /B "%ROOT%\org-sidecar\start-sidecar.bat" ) +:afterSidecar echo [freeos] home=%FREEOS_HOME% echo [freeos] http://%HOST%:%PORT% diff --git a/desktop/portable/templates/start.sh b/desktop/portable/templates/start.sh index 9e5ebf13..494c3c53 100755 --- a/desktop/portable/templates/start.sh +++ b/desktop/portable/templates/start.sh @@ -58,8 +58,6 @@ done export FREEOS_HOME OCTOP_HOME export FREEOS_ORG_ENABLE="${FREEOS_ORG_ENABLE:-1}" -export FREEOS_ORG_SIDECAR_URL="${FREEOS_ORG_SIDECAR_URL:-http://127.0.0.1:3780}" -export OPENXYOS_BASE_URL="${OPENXYOS_BASE_URL:-${FREEOS_ORG_SIDECAR_URL}}" mkdir -p "$FREEOS_HOME" PY="" @@ -78,10 +76,19 @@ export PYTHONUTF8=1 export PYTHONIOENCODING=utf-8 unset PYTHONPATH || true -if [[ -x "${ROOT}/org-sidecar/start-sidecar.sh" ]]; then - echo "[freeos] organization sidecar → ${FREEOS_ORG_SIDECAR_URL}" - "${ROOT}/org-sidecar/start-sidecar.sh" & -fi +case "${FREEOS_ORG_SIDECAR:-}" in + 1|true|yes|on) + export FREEOS_ORG_SIDECAR_URL="${FREEOS_ORG_SIDECAR_URL:-http://127.0.0.1:3780}" + export OPENXYOS_BASE_URL="${OPENXYOS_BASE_URL:-${FREEOS_ORG_SIDECAR_URL}}" + if [[ -x "${ROOT}/org-sidecar/start-sidecar.sh" ]]; then + echo "[freeos] optional organization sidecar → ${FREEOS_ORG_SIDECAR_URL}" + "${ROOT}/org-sidecar/start-sidecar.sh" & + fi + ;; + *) + echo "[freeos] organization is in-host (set FREEOS_ORG_SIDECAR=1 for the Node sidecar)" + ;; +esac echo "[freeos] home=${FREEOS_HOME}" echo "[freeos] http://${HOST}:${PORT}" diff --git a/desktop/src/Taskfile.yml b/desktop/src/Taskfile.yml index 0227d839..6931d4e1 100644 --- a/desktop/src/Taskfile.yml +++ b/desktop/src/Taskfile.yml @@ -18,7 +18,7 @@ tasks: - task: "{{OS}}:build" package: - summary: Packages a production build + summary: Packages a production build (Windows NSIS is slim unless SHIP_OPENXYOS_RUNTIME=1) cmds: - task: "{{OS}}:package" diff --git a/desktop/src/build/config.yml b/desktop/src/build/config.yml index edabf6a7..21c2ea70 100644 --- a/desktop/src/build/config.yml +++ b/desktop/src/build/config.yml @@ -8,10 +8,10 @@ info: companyName: "XYAI Studio" productName: "FreeOS" productIdentifier: "com.xyaistudio.freeos" - description: "FreeOS desktop — Octop shell + openXYOS" + description: "FreeOS desktop — Python host with in-host organization" copyright: "(c) 2026, XYAI Studio" - comments: "Wails v3 + green portable + openXYOS sidecar" - version: "0.0.1" + comments: "Wails v3 + green portable. Node sidecar optional via FREEOS_ORG_SIDECAR=1." + version: "0.0.2" dev_mode: root_path: . diff --git a/desktop/src/build/windows/Taskfile.yml b/desktop/src/build/windows/Taskfile.yml index bd6ff0bc..4aa63598 100644 --- a/desktop/src/build/windows/Taskfile.yml +++ b/desktop/src/build/windows/Taskfile.yml @@ -41,7 +41,7 @@ tasks: PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/FreeOS-portable-windows-%s-%s.zip" (.ARCH | default ARCH) (.VERSION | default "dev"))}}' stage:openxyos-runtime: - summary: Extract org-sidecar from the portable zip for the NSIS payload + summary: Opt-in only — extract org-sidecar from the portable zip for NSIS cmds: - cmd: >- python "{{.ROOT_DIR}}/build/stage_openxyos_runtime.py" "{{.PORTABLE_ZIP}}" @@ -54,18 +54,32 @@ tasks: vars: PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/FreeOS-portable-windows-%s-%s.zip" (.ARCH | default ARCH) (.VERSION | default "dev"))}}' + maybe-stage:openxyos-runtime: + summary: Stage openxyos-runtime.zip only when SHIP_OPENXYOS_RUNTIME=1 + status: + - test "{{.SHIP_OPENXYOS_RUNTIME}}" != "1" + cmds: + - task: stage:openxyos-runtime + vars: + ARCH: "{{.ARCH}}" + PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" + VERSION: "{{.VERSION}}" + vars: + SHIP_OPENXYOS_RUNTIME: '{{.SHIP_OPENXYOS_RUNTIME | default "0"}}' + package: - summary: Packages an NSIS installer with the portable runtime embedded + summary: Packages a slim NSIS installer (Python host). Pass SHIP_OPENXYOS_RUNTIME=1 to embed openxyos-runtime.zip. cmds: - task: stage:portable vars: ARCH: "{{.ARCH}}" PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" - - task: stage:openxyos-runtime + - task: maybe-stage:openxyos-runtime vars: ARCH: "{{.ARCH}}" PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" VERSION: "{{.VERSION}}" + SHIP_OPENXYOS_RUNTIME: "{{.SHIP_OPENXYOS_RUNTIME}}" - task: build vars: PRODUCTION: "true" @@ -75,6 +89,7 @@ tasks: vars: ARCH: "{{.ARCH}}" VERSION: "{{.VERSION}}" + SHIP_OPENXYOS_RUNTIME: "{{.SHIP_OPENXYOS_RUNTIME}}" - cmd: >- powershell -NoProfile -Command "Remove-Item -Force '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-windows-{{.ARCH}}.zip' -ErrorAction SilentlyContinue; @@ -84,6 +99,7 @@ tasks: vars: ARCH: "{{.ARCH | default ARCH}}" VERSION: '{{.VERSION | default "dev"}}' + SHIP_OPENXYOS_RUNTIME: '{{.SHIP_OPENXYOS_RUNTIME | default "0"}}' PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/FreeOS-portable-windows-%s-%s.zip" .ARCH .VERSION)}}' ARCHIVE: '{{.BIN_DIR}}/{{.APP_NAME}}-desktop-windows-{{.ARCH}}-{{.VERSION}}.exe' @@ -108,7 +124,8 @@ tasks: vars: ARCH: '{{.ARCH | default ARCH}}' VERSION: '{{.VERSION | default "dev"}}' - VERSION_NSIS_DEFINE: '{{if and .VERSION (ne .VERSION "dev")}}-DINFO_PRODUCTVERSION="{{.VERSION}}" {{end}}' + VERSION_NSIS_DEFINE: '{{if and .VERSION (ne .VERSION "dev")}}-DINFO_PRODUCTVERSION="{{.VERSION}}" {{end}}{{if eq .SHIP_OPENXYOS_RUNTIME "1"}}-DOPENXYOS_RUNTIME_ZIP_PRESENT {{end}}' + SHIP_OPENXYOS_RUNTIME: '{{.SHIP_OPENXYOS_RUNTIME | default "0"}}' ARG_FLAG: '{{if eq (.ARCH | default ARCH) "amd64"}}AMD64{{else}}ARM64{{end}}' ARCHIVE: '{{.BIN_DIR}}/{{.APP_NAME}}-desktop-windows-{{.ARCH}}-{{.VERSION}}.exe' diff --git a/desktop/src/build/windows/info.json b/desktop/src/build/windows/info.json index f118ea00..71e63a03 100644 --- a/desktop/src/build/windows/info.json +++ b/desktop/src/build/windows/info.json @@ -1,15 +1,15 @@ { "fixed": { - "file_version": "0.0.1" + "file_version": "0.0.2" }, "info": { "0000": { - "ProductVersion": "0.0.1", + "ProductVersion": "0.0.2", "CompanyName": "XYAI Studio", "FileDescription": "FreeOS desktop", "LegalCopyright": "Copyright © 2026 XYAI Studio", "ProductName": "FreeOS", - "Comments": "FreeOS desktop — Octop shell + openXYOS" + "Comments": "FreeOS desktop — Python host with in-host organization" } } } diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 0238abda..ea00e263 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -52,10 +52,14 @@ ManifestDPIAware true LangString FINISH_RUN ${LANG_SIMPCHINESE} "运行 FreeOS" LangString FINISH_RUN ${LANG_ENGLISH} "Run FreeOS" -LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "创建 openXYOS 工作目录(写入 %LOCALAPPDATA%\\FreeOS\\openxyos)" -LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Create the openXYOS work directory (%LOCALAPPDATA%\\FreeOS\\openxyos)" -LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制 openXYOS 运行包(前端+后端)与预配子进程" -LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the openXYOS runtime payload and provisioner subprocess" +LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE} "组织能力在 FreeOS 宿主内运行;openXYOS Node 运行包可选" +LangString OPENXYOS_WORKDIR ${LANG_ENGLISH} "Organization runs in the FreeOS host; the openXYOS Node runtime is optional" +LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制可选的 openXYOS 运行包(不解压、不启动)" +LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the optional openXYOS runtime payload (do not extract or start)" +LangString OPENXYOS_OPTIONAL_SKIP ${LANG_SIMPCHINESE} "跳过 openXYOS 解压与 livez。安装继续;组织页使用宿主内能力。" +LangString OPENXYOS_OPTIONAL_SKIP ${LANG_ENGLISH} "Skipping openXYOS extract and livez. Setup continues; Organization uses the in-host module." +LangString OPENXYOS_OPTIONAL_ABSENT ${LANG_SIMPCHINESE} "本安装包未包含 openXYOS 运行包。安装继续;组织页使用宿主内能力。" +LangString OPENXYOS_OPTIONAL_ABSENT ${LANG_ENGLISH} "This package has no openXYOS runtime zip. Setup continues; Organization uses the in-host module." LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE} "正在以子进程部署 openXYOS(解压、启动前后端、等待健康检查)" LangString OPENXYOS_PROVISION ${LANG_ENGLISH} "Running the openXYOS provisioner subprocess (extract, start FE/BE, wait for health)" LangString OPENXYOS_PROVISION_DETAIL ${LANG_SIMPCHINESE} "完整日志:%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log(UTF-8);此处只显示步骤结果" diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index 9e3aba3f..14485bf9 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -17,7 +17,7 @@ !define INFO_PRODUCTNAME "FreeOS" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "0.0.1" + !define INFO_PRODUCTVERSION "0.0.2" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "(c) 2026, XYAI Studio" @@ -34,16 +34,13 @@ !define WAILS_INSTALL_SCOPE "machine" !endif -# Prebuilt FE+BE zip must exist when makensis runs (CI: stage:openxyos-runtime). -# A README-only $INSTDIR\openxyos is not a shippable installer. +# Optional openXYOS Node runtime. Organization runs in the FreeOS host. +# OPENXYOS_RUNTIME_ZIP_PRESENT is OFF by default. A leftover zip on disk +# must not silently inflate the installer — only opt-in makensis +# (-DOPENXYOS_RUNTIME_ZIP_PRESENT from SHIP_OPENXYOS_RUNTIME=1) ships it. !ifndef OPENXYOS_RUNTIME_ZIP !define OPENXYOS_RUNTIME_ZIP "..\openxyos-runtime.zip" !endif -!if /FileExists "${OPENXYOS_RUNTIME_ZIP}" - !define OPENXYOS_RUNTIME_ZIP_PRESENT -!else - !error "openxyos-runtime.zip missing at desktop/src/build/windows/openxyos-runtime.zip; run stage:openxyos-runtime before makensis" -!endif !ifndef REQUEST_EXECUTION_LEVEL !if "${WAILS_INSTALL_SCOPE}" == "user" @@ -145,76 +142,30 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" ${EndIf} !macroend -# After the FreeOS shell files are copied, run the shipped openXYOS -# provisioner as a child process (nsExec::Exec, not ExecToLog). The -# parent waits for exit 0. Child stdout is discarded (cmd >nul) so -# UTF-8 Node/PowerShell logs do not mojibake in the ANSI detail list. -# Localized DetailPrint LangStrings are the only NSIS-visible progress. -# The child extracts with tar.exe, starts FE/BE at medium IL, waits for -# livez, and writes .install-ready only when healthy. Do not FileWrite -# goto-label .cmd scripts here. Do not register logon autostart. +# Organization is in-host. Default Setup copies no openxyos-runtime.zip. +# Opt-in builds (-DOPENXYOS_RUNTIME_ZIP_PRESENT) copy the zip + scripts +# but still never extract, start Node, wait for livez, or Abort. +# Do not FileWrite goto-label .cmd scripts here. Do not register logon autostart. !macro wails.provisionOpenXYOS !insertmacro wails.userLocalAppData SetDetailsPrint both DetailPrint "$(OPENXYOS_WORKDIR)" CreateDirectory "$INSTDIR\openxyos" - CreateDirectory "$INSTDIR\openxyos-runtime" - CreateDirectory "$R6\FreeOS\openxyos" - DetailPrint "$(OPENXYOS_COPY_ZIP)" - File "/oname=openxyos-runtime.zip" "${OPENXYOS_RUNTIME_ZIP}" - File "provision-openxyos.ps1" - File "provision-openxyos.cmd" - File "start-sidecar.ps1" - File "start-sidecar.cmd" - DetailPrint "$(OPENXYOS_PROVISION)" - DetailPrint "$(OPENXYOS_PROVISION_DETAIL)" - StrCpy $R4 '"$INSTDIR\provision-openxyos.cmd" -ZipPath "$INSTDIR\openxyos-runtime.zip" -InstallDir "$INSTDIR" -LiveDir "$R6\FreeOS\openxyos"' - nsExec::Exec $R4 - Pop $0 - DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" - ${If} $0 == 12 - DetailPrint "$(OPENXYOS_PROVISION_RETRY)" - nsExec::Exec $R4 - Pop $0 - DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" - ${EndIf} - ${If} $0 == 0 - IfFileExists "$R6\FreeOS\openxyos\.install-ready" 0 openxyosMissingMarker - DetailPrint "$(OPENXYOS_PROVISION_OK)" - Goto openxyosProvisionDone - openxyosMissingMarker: - StrCpy $0 13 - ${EndIf} - !insertmacro wails.openxyosFailDetail - DetailPrint $R9 - IfSilent openxyosProvisionSilent openxyosProvisionAsk - openxyosProvisionSilent: - SetErrorLevel $0 - Abort - openxyosProvisionAsk: - MessageBox MB_RETRYCANCEL|MB_ICONSTOP "$(OPENXYOS_PROVISION_FAIL)$\r$\n$\r$\n$R9$\r$\n$\r$\n$(OPENXYOS_FAIL_CODE)$0$\r$\n$(OPENXYOS_FAIL_LOG)" IDRETRY openxyosProvisionRetry - SetErrorLevel $0 - Abort - openxyosProvisionRetry: - DetailPrint "$(OPENXYOS_PROVISION_RETRY)" - nsExec::Exec $R4 - Pop $0 - DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" - ${If} $0 == 0 - IfFileExists "$R6\FreeOS\openxyos\.install-ready" openxyosProvisionDone 0 - StrCpy $0 13 - ${EndIf} - !insertmacro wails.openxyosFailDetail - DetailPrint $R9 - IfSilent openxyosProvisionSilent2 openxyosProvisionFinal - openxyosProvisionSilent2: - SetErrorLevel $0 - Abort - openxyosProvisionFinal: - MessageBox MB_OK|MB_ICONSTOP "$(OPENXYOS_PROVISION_FAIL)$\r$\n$\r$\n$R9$\r$\n$\r$\n$(OPENXYOS_FAIL_CODE)$0$\r$\n$(OPENXYOS_FAIL_LOG)" - SetErrorLevel $0 - Abort - openxyosProvisionDone: + !ifdef OPENXYOS_RUNTIME_ZIP_PRESENT + !if /FileExists "${OPENXYOS_RUNTIME_ZIP}" + DetailPrint "$(OPENXYOS_COPY_ZIP)" + File "/oname=openxyos-runtime.zip" "${OPENXYOS_RUNTIME_ZIP}" + File "provision-openxyos.ps1" + File "provision-openxyos.cmd" + File "start-sidecar.ps1" + File "start-sidecar.cmd" + DetailPrint "$(OPENXYOS_OPTIONAL_SKIP)" + !else + !error "OPENXYOS_RUNTIME_ZIP_PRESENT is set but ${OPENXYOS_RUNTIME_ZIP} is missing" + !endif + !else + DetailPrint "$(OPENXYOS_OPTIONAL_ABSENT)" + !endif SetDetailsPrint listonly !macroend diff --git a/desktop/src/desktop_copy.go b/desktop/src/desktop_copy.go index f8d87d58..be5020d0 100644 --- a/desktop/src/desktop_copy.go +++ b/desktop/src/desktop_copy.go @@ -35,8 +35,8 @@ var desktopCopy = map[Locale]map[string]string{ copyStatusConnecting: "Connecting to FreeOS…", copyStatusCheckingRuntime: "Checking the runtime…", copyStatusStartingService: "Starting the FreeOS service…", - copyStatusStartingOrg: "Starting the organization console…", - copyStatusProvisioningOrg: "Preparing the local openXYOS environment…", + copyStatusStartingOrg: "Starting FreeOS…", + copyStatusProvisioningOrg: "Preparing the optional openXYOS sidecar…", copyStatusReady: "FreeOS is ready", copyStatusUsingRuntime: "Using the existing runtime…", copyStatusBackupDatabase: "Desktop update %s found. Backing up the database…", @@ -62,8 +62,8 @@ var desktopCopy = map[Locale]map[string]string{ copyStatusConnecting: "正在连接 FreeOS…", copyStatusCheckingRuntime: "正在检查运行环境…", copyStatusStartingService: "正在启动 FreeOS 服务…", - copyStatusStartingOrg: "正在启动组织控制台…", - copyStatusProvisioningOrg: "正在准备本机 openXYOS 环境…", + copyStatusStartingOrg: "正在启动 FreeOS…", + copyStatusProvisioningOrg: "正在准备可选的 openXYOS 边车…", copyStatusReady: "FreeOS 已就绪", copyStatusUsingRuntime: "正在使用已有运行环境…", copyStatusBackupDatabase: "发现客户端新版 %s,正在备份数据库…", diff --git a/desktop/src/download.go b/desktop/src/download.go index d024c4a1..da187798 100644 --- a/desktop/src/download.go +++ b/desktop/src/download.go @@ -125,7 +125,7 @@ func shouldReplacePortable(root string) bool { return true } // Keep a newer in-app FreeOS portable over an older bundled zip. - // Do not keep Octop 0.9 / 1.0 leftovers — FreeOS is 0.0.1 and must replace them. + // Do not keep Octop 0.9 / 1.0 leftovers — FreeOS is 0.0.2 and must replace them. if currentVersion != "" && bundledVersion != "" && compareVersions(bundledVersion, currentVersion) < 0 && !isOctopLineageRuntime(root) { diff --git a/desktop/src/main.go b/desktop/src/main.go index 6472c70e..100cf7d1 100644 --- a/desktop/src/main.go +++ b/desktop/src/main.go @@ -191,8 +191,12 @@ func (a *App) boot() { } root := portableDir() openxyosReady := sidecarBundleReady(openxyosUserWorkDir()) - if _, perr := provisionOpenXYOS(root, locale, a.setStatus); perr != nil { - log.Printf("openXYOS provision: %v", perr) + if orgSidecarWanted() { + if _, perr := provisionOpenXYOS(root, locale, a.setStatus); perr != nil { + log.Printf("openXYOS provision: %v", perr) + } + } else { + log.Printf("in-host organization; Node sidecar skipped (set FREEOS_ORG_SIDECAR=1 to enable)") } port := chooseHostPort(s.Port) if port != s.Port { @@ -202,12 +206,14 @@ func (a *App) boot() { stopOctop(a.cmd) stopOctop(a.sidecar) a.setStatus(desktopText(locale, copyStatusStartingOrg)) - sidecar, serr := startOrgSidecar(root, port) - a.sidecar = sidecar - if serr != nil { - logStartupError("organization sidecar", serr) - } else if sidecar == nil { - log.Printf("organization sidecar not bundled under %s", orgSidecarDir(root)) + if orgSidecarWanted() { + sidecar, serr := startOrgSidecar(root, port) + a.sidecar = sidecar + if serr != nil { + logStartupError("organization sidecar", serr) + } else if sidecar == nil { + log.Printf("organization sidecar not bundled under %s", orgSidecarDir(root)) + } } cmd, err := startOctop(root, port) a.cmd = cmd @@ -230,7 +236,7 @@ func (a *App) boot() { showFatalError("FreeOS", formatFatalStartup(locale, err)) return } - if sidecarReady(root) { + if orgSidecarWanted() && sidecarReady(root) { wait := 30 * time.Second if firstLaunch && !openxyosReady { wait = 60 * time.Second diff --git a/desktop/src/process.go b/desktop/src/process.go index 5a55f14c..12e31e58 100644 --- a/desktop/src/process.go +++ b/desktop/src/process.go @@ -22,29 +22,32 @@ const defaultSidecarPort = 3780 func hostLaunchEnv(root string, port int) map[string]string { home := productHome() env := map[string]string{ - "FREEOS_HOME": home, - "OCTOP_HOME": home, - "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), - "PYTHONNOUSERSITE": "1", - "PYTHONUTF8": "1", - "PYTHONIOENCODING": "utf-8", - "PYTHONPATH": "", - "FREEOS_ORG_ENABLE": "1", - "FREEOS_ORG_SIDECAR_URL": sidecarURL(), - "FREEOS_ORG_SIDECAR_PORT": strconv.Itoa(defaultSidecarPort), - "OPENXYOS_BASE_URL": sidecarURL(), - "OCTOP_PORT": strconv.Itoa(port), - "OCTOP_DESKTOP": "1", - "FREEOS_DESKTOP": "1", + "FREEOS_HOME": home, + "OCTOP_HOME": home, + "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), + "PYTHONNOUSERSITE": "1", + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + "PYTHONPATH": "", + "FREEOS_ORG_ENABLE": "1", + "OCTOP_PORT": strconv.Itoa(port), + "OCTOP_DESKTOP": "1", + "FREEOS_DESKTOP": "1", } - if bundle := resolveSidecarDir(root); bundle != "" { - env["FREEOS_OPENXYOS_HOME"] = bundle - } - if install := openxyosInstallDir(); install != "" { - env["FREEOS_OPENXYOS_INSTALL"] = install - } - if _, _, ingest, err := loadOrCreateSidecarSecrets(home); err == nil && ingest != "" { - env["FREEOS_INGEST_TOKEN"] = ingest + if orgSidecarWanted() { + env["FREEOS_ORG_SIDECAR"] = "1" + env["FREEOS_ORG_SIDECAR_URL"] = sidecarURL() + env["FREEOS_ORG_SIDECAR_PORT"] = strconv.Itoa(defaultSidecarPort) + env["OPENXYOS_BASE_URL"] = sidecarURL() + if bundle := resolveSidecarDir(root); bundle != "" { + env["FREEOS_OPENXYOS_HOME"] = bundle + } + if install := openxyosInstallDir(); install != "" { + env["FREEOS_OPENXYOS_INSTALL"] = install + } + if _, _, ingest, err := loadOrCreateSidecarSecrets(home); err == nil && ingest != "" { + env["FREEOS_INGEST_TOKEN"] = ingest + } } return env } diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index ae72c314..4c3277de 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -17,6 +17,15 @@ import ( "time" ) +func orgSidecarWanted() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("FREEOS_ORG_SIDECAR"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func orgSidecarDir(root string) string { return filepath.Join(root, "org-sidecar") } @@ -413,6 +422,9 @@ func startViaWindowsHelper(bundle string) (*exec.Cmd, error) { } func startOrgSidecar(root string, dashboardPort int) (*exec.Cmd, error) { + if !orgSidecarWanted() { + return nil, nil + } bundle := resolveSidecarDir(root) if bundle == "" { work := openxyosUserWorkDir() diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index bfe463a6..e8041410 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -304,6 +304,19 @@ func TestSidecarLiveReadsHealthURL(t *testing.T) { } } +func TestStartOrgSidecarSkipsWhenNotOptedIn(t *testing.T) { + root := t.TempDir() + writeSidecarBundle(t, filepath.Join(root, "org-sidecar")) + t.Setenv("FREEOS_ORG_SIDECAR", "") + cmd, err := startOrgSidecar(root, 8088) + if err != nil { + t.Fatal(err) + } + if cmd != nil { + t.Fatal("Node sidecar must stay off unless FREEOS_ORG_SIDECAR is set") + } +} + func TestStartOrgSidecarSkipsWhenNoBundle(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/desktop/src/settings_home_test.go b/desktop/src/settings_home_test.go index 1b6c57ad..8ebc8093 100644 --- a/desktop/src/settings_home_test.go +++ b/desktop/src/settings_home_test.go @@ -48,8 +48,11 @@ func TestHostLaunchEnvSetsFreeosHomeAndOrgEnable(t *testing.T) { if env["FREEOS_ORG_ENABLE"] != "1" { t.Fatalf("org enable: %+v", env) } - if env["FREEOS_ORG_SIDECAR_URL"] != "http://127.0.0.1:3780" { - t.Fatalf("sidecar url: %+v", env) + if _, ok := env["FREEOS_ORG_SIDECAR_URL"]; ok { + t.Fatalf("sidecar url must stay unset by default: %+v", env) + } + if _, ok := env["OPENXYOS_BASE_URL"]; ok { + t.Fatalf("OPENXYOS_BASE_URL must stay unset by default: %+v", env) } if env["OCTOP_DESKTOP"] != "1" || env["FREEOS_DESKTOP"] != "1" { t.Fatalf("desktop flags: %+v", env) @@ -59,6 +62,19 @@ func TestHostLaunchEnvSetsFreeosHomeAndOrgEnable(t *testing.T) { } } +func TestHostLaunchEnvSidecarOptIn(t *testing.T) { + home := t.TempDir() + t.Setenv("FREEOS_HOME", home) + t.Setenv("FREEOS_ORG_SIDECAR", "1") + env := hostLaunchEnv(filepath.Join(home, "portable"), 8088) + if env["FREEOS_ORG_SIDECAR_URL"] != "http://127.0.0.1:3780" { + t.Fatalf("sidecar url: %+v", env) + } + if env["OPENXYOS_BASE_URL"] != "http://127.0.0.1:3780" { + t.Fatalf("openxyos url: %+v", env) + } +} + func TestWithDesktopQueryMarksSpa(t *testing.T) { if got := withDesktopQuery("http://127.0.0.1:8088/"); got != "http://127.0.0.1:8088/?desktop=1" { t.Fatalf("got %q", got) diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 57f51c27..3943bded 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -46,36 +46,40 @@ See [NOTICE](../NOTICE) and `modules/openxyos/TRADEMARKS.md`. ## Running topology +Desktop keeps **one** resident runtime: the FreeOS/Octop Python host. +Organization control-plane capabilities (`org_os`, `/api/org-module/*`, +the Organization page) live in that host. The full openXYOS Node stack on +`127.0.0.1:3780` is **optional** (export / sync / advanced deploy). It is +not required to install FreeOS, open Organization, or run +`freeos org loop run`. Default Windows NSIS and portable zips do **not** +embed `openxyos-runtime.zip` (the ~500MB Node payload). Opt-in builds set +`SHIP_OPENXYOS_RUNTIME=1`. + 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. +generates module skills, compiles blueprints into chat-usable colleagues, +and applies asset packs onto openXYOS-shaped **in-host** surfaces +(`{FREEOS_HOME}/openxyos-mirror/`, lifecycle registry, org-skills). ``` ┌─────────────────────────────────────────────┐ Browser / IM │ FreeOS dashboard + Octop IM channels │ - │ /organization · /approve · /pending │ + │ /organization (native) · /experts · /approve│ └───────────────┬─────────────────────────────┘ │ JWT / IM session ┌───────────────▼─────────────────────────────┐ Host │ FastAPI src/octop/api/app.py │ - (data plane) │ /api/* Octop routers │ - │ /api/org-module/status|catalog|PATCH │ - │ /api/org-module/governance/* │ - │ /api/org-module/skills/* │ - │ /api/org-module/sidecar/* (BFF proxy) │ - │ PluginManager + bundled org-os │ - │ xyos-governance-mcp (stdio MCP) │ + (single runtime)│ /api/* Octop routers │ + │ /api/org-module/status|catalog|overview │ + │ /api/org-module/assemble|pack|loop|produce │ + │ /api/org-module/employees|assets|governance │ + │ in-host org storage + xyos-governance-mcp │ └───────┬───────────────────┬─────────────────┘ - │ HTTP │ durable pause - │ X-FreeOS-User* │ ~/.freeos/governance/ - │ X-FreeOS-Tenant-Id│ + │ optional HTTP │ durable pause + │ if opted in │ ~/.freeos/governance/ ┌───────▼─────────┐ ┌─────▼─────────────────┐ - Sidecar │ openXYOS :3780 │ │ Pause + audit JSONL │ - (control plane)│ /api/governance │ │ Human must approve │ - │ /api/module-… │ │ before execute=true │ - │ own SQL.js/PG │ └───────────────────────┘ - └─────────────────┘ + Optional │ openXYOS :3780 │ │ Pause + audit JSONL │ + Node sidecar │ export / sync │ │ Human must approve │ + └─────────────────┘ └───────────────────────┘ ``` **Why this topology for 2 + 3** diff --git a/pyproject.toml b/pyproject.toml index 84c2168c..2adc247f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.0.1" +version = "0.0.2" description = "FreeOS — self-hosted multi-user multi-agent assistant with an optional organization OS module (Octop-compatible package name)" readme = "README.md" license = { text = "MIT" } diff --git a/scripts/release_download_links.py b/scripts/release_download_links.py index 4db77cfb..00378222 100644 --- a/scripts/release_download_links.py +++ b/scripts/release_download_links.py @@ -3,8 +3,8 @@ Same pattern as clash-verge-rev: interpolate TAG / VERSION into a fixed filename table. Do not scrape the assets list. - python3 scripts/release_download_links.py 0.0.1 - python3 scripts/release_download_links.py v0.0.1 + python3 scripts/release_download_links.py 0.0.2 + python3 scripts/release_download_links.py v0.0.2 """ from __future__ import annotations diff --git a/src/octop/__init__.py b/src/octop/__init__.py index 7f31da6c..c9aa8c57 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.0.1" +__version__ = "0.0.2" diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index faf2f322..2cb37772 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -615,7 +615,7 @@ async def run_loop( 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(), + sidecar_url=body.base_url or service.explicit_sidecar_url(), config_path=service.config_path, owner_user_id=int(getattr(user, "id", 0) or 0) or None, ) diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index fd1d13bd..fe4d1210 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -582,33 +582,25 @@ def _apply_desktop_org_defaults(self) -> None: logger.info("organization module enabled (FREEOS_ORG_ENABLE)") def _ensure_desktop_org_sidecar(self) -> None: - """Start local openXYOS FE+BE with the FreeOS host, not only on Organization.""" + """Optionally start the Node sidecar. Organization itself is in-host.""" + from octop.modules.org_os.service import org_module_from_paths, org_sidecar_wanted + desktop = ( (os.environ.get("OCTOP_DESKTOP") or os.environ.get("FREEOS_DESKTOP") or "") .strip() .lower() ) org_flag = (os.environ.get("FREEOS_ORG_ENABLE") or "").strip().lower() - from octop.modules.org_os.sidecar_launch import ( - ensure_sidecar, - find_sidecar_runtime, - sidecar_install_ready, - ) - desktop_on = desktop in {"1", "true", "yes", "on"} org_on = org_flag in {"1", "true", "yes", "on"} - if ( - not desktop_on - and not org_on - and not sidecar_install_ready() - and find_sidecar_runtime() is None - ): - return - from octop.modules.org_os.service import org_module_from_paths - service = org_module_from_paths(self.paths) if desktop_on or org_on: service.enable_from_desktop_env() + if not org_sidecar_wanted(): + logger.info("organization sidecar skipped (in-host org; set FREEOS_ORG_SIDECAR=1)") + return + from octop.modules.org_os.sidecar_launch import ensure_sidecar + result = ensure_sidecar(service) logger.info( "organization sidecar ensure started=%s already=%s reachable=%s detail=%s", diff --git a/src/octop/modules/org_os/empower.py b/src/octop/modules/org_os/empower.py index 0115e356..8a60725d 100644 --- a/src/octop/modules/org_os/empower.py +++ b/src/octop/modules/org_os/empower.py @@ -76,7 +76,7 @@ def assemble_from_blueprint( notes.extend(inbound.notes) notes.append("imported openXYOS catalog/policies from the live sidecar") else: - notes.append("sidecar offline; compiling bundled openXYOS blueprint fixtures") + notes.append("using in-host org storage and bundled openXYOS blueprint fixtures") store = LifecycleStore(service.home, tid) paths = [blueprint_fixture(), *extra_blueprint_fixtures()] @@ -85,7 +85,7 @@ def assemble_from_blueprint( path, home=service.home, tenant_id=tid, - sidecar_url=service.sidecar_url(), + sidecar_url=service.explicit_sidecar_url(), ) register_compiled( store, @@ -196,7 +196,7 @@ def produce_from_corpus( }, home=service.home, tenant_id=tid, - sidecar_url=service.sidecar_url(), + sidecar_url=service.explicit_sidecar_url(), ) store = LifecycleStore(service.home, tid) register_compiled( diff --git a/src/octop/modules/org_os/loop/run.py b/src/octop/modules/org_os/loop/run.py index 41102ef8..e511ca4c 100644 --- a/src/octop/modules/org_os/loop/run.py +++ b/src/octop/modules/org_os/loop/run.py @@ -3,13 +3,12 @@ from __future__ import annotations import json -import time 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 OpenXyosControlClient, resolve_control_plane_url +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 ( @@ -69,17 +68,6 @@ def to_dict(self) -> dict[str, Any]: } -def _wait_control_plane(url: str, *, home: Path, attempts: int = 12) -> bool: - if not url: - return False - client = OpenXyosControlClient(url, home=home, timeout=2.0, retries=1) - for _ in range(max(attempts, 1)): - if client.get_json("/api/health/livez") is not None or client.bridge_ready(): - return True - time.sleep(0.5) - return False - - def _promote(store: LifecycleStore, slug: str) -> tuple[dict[str, str], list[str]]: """Walk *slug* toward ``active`` along allowed edges only. @@ -137,9 +125,10 @@ def run_growth_loop( ) -> 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. + Uses bundled fixtures and the in-host org registry. When + ``OPENXYOS_BASE_URL`` (or an explicit *sidecar_url*) is set, outbound + packs are also POSTed to that optional control plane. The loop never + starts or waits for a local Node sidecar. """ home = Path(home) home.mkdir(parents=True, exist_ok=True) @@ -153,19 +142,6 @@ def run_growth_loop( if not blueprint.is_file(): raise FileNotFoundError(f"blueprint fixture missing: {blueprint}") control_url = resolve_control_plane_url(sidecar_url) - if control_url: - _wait_control_plane(control_url, home=home, attempts=8) - else: - health = service.probe_sidecar(timeout=0.4) - if health.reachable: - control_url = health.url - else: - from octop.modules.org_os.sidecar_launch import ensure_sidecar, sidecar_can_start - - if sidecar_can_start(): - started = ensure_sidecar(service, wait=12.0) - if started.reachable: - control_url = started.url or service.sidecar_url() imported = import_openxyos_assets( home, diff --git a/src/octop/modules/org_os/overview.py b/src/octop/modules/org_os/overview.py index c98ba600..378d6a19 100644 --- a/src/octop/modules/org_os/overview.py +++ b/src/octop/modules/org_os/overview.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any +from octop.modules.org_os.apply.client import mirror_root from octop.modules.org_os.catalog import OPENXYOS_MODULES, catalog_keys from octop.modules.org_os.lifecycle.store import LifecycleStore from octop.modules.org_os.runtime.spawn import list_spawned_agents @@ -27,6 +28,54 @@ def _count_skill_dirs(home: Path) -> int: return sum(1 for child in root.iterdir() if child.is_dir() and (child / "SKILL.md").is_file()) +def _list_len(doc: Any, key: str) -> int: + if isinstance(doc, dict) and isinstance(doc.get(key), list): + return len(doc[key]) + if isinstance(doc, list): + return len(doc) + return 0 + + +def _mirror_surfaces(home: Path, tenant_id: str) -> dict[str, int]: + root = mirror_root(home, tenant_id) + counts = {"employees": 0, "talent": 0, "skills": 0, "plugins": 0} + if not root.is_dir(): + return counts + mapping = { + "employees": ("employees.json", "employees"), + "talent": ("talent.json", "talent"), + "skills": ("skills.json", "skills"), + "plugins": ("plugins.json", "plugins"), + } + for surface, (name, key) in mapping.items(): + path = root / name + if not path.is_file(): + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + counts[surface] = _list_len(raw, key) + return counts + + +def _colleague_rows(home: Path, tenant_id: str) -> list[dict[str, Any]]: + spawned = {row.slug: row for row in list_spawned_agents(home)} + rows: list[dict[str, Any]] = [] + for rec in LifecycleStore(home, tenant_id).list(): + agent = spawned.get(rec.slug) + rows.append( + { + "slug": rec.slug, + "name": rec.name or rec.slug, + "lifecycle": rec.lifecycle, + "agent_id": rec.agent_id or (agent.agent_id if agent else ""), + "spawned": agent is not None or bool(rec.agent_id), + } + ) + return rows + + def _read_loop_proof(home: Path) -> dict[str, Any] | None: path = home / "asset-packs" / "loop-proof.json" if not path.is_file(): @@ -56,10 +105,17 @@ class DualLoopOverview: openxyos: dict[str, Any] last_loop: dict[str, Any] | None = None notes: list[str] = field(default_factory=list) + runtime: str = "in_host" + sidecar_optional: bool = True + colleagues: list[dict[str, Any]] = field(default_factory=list) + experts: list[dict[str, Any]] = field(default_factory=list) + org_surfaces: dict[str, int] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return { "enabled": self.enabled, + "runtime": self.runtime, + "sidecar_optional": self.sidecar_optional, "sidecar_reachable": self.sidecar_reachable, "sidecar_embed_ok": self.sidecar_embed_ok, "sidecar_url": self.sidecar_url, @@ -70,6 +126,9 @@ def to_dict(self) -> dict[str, Any]: "last_sync": self.last_sync, "freeos": dict(self.freeos), "openxyos": dict(self.openxyos), + "colleagues": list(self.colleagues), + "experts": list(self.experts), + "org_surfaces": dict(self.org_surfaces), "last_loop": self.last_loop, "notes": list(self.notes), "catalog": list(OPENXYOS_MODULES), @@ -90,6 +149,8 @@ def build_overview( tid = service.tenant_id() or "default" colleagues = LifecycleStore(service.home, tid).list() spawned = list_spawned_agents(service.home) + colleague_rows = _colleague_rows(service.home, tid) + org_surfaces = _mirror_surfaces(service.home, tid) org_skills = _count_skill_dirs(service.home) proof = _read_loop_proof(service.home) last_sync = None @@ -122,7 +183,8 @@ def build_overview( "approvals": 0, } notes = [ - "FreeOS does the work. openXYOS owns organization and governance.", + "Organization runs in the FreeOS Python host. The Node sidecar is optional.", + "FreeOS does the work. Organization capabilities (catalog, employees, growth loop) live in-host.", "Data plane: colleagues / experts / skills / MCP / tasks. Control plane: department employees / blueprints / modules / governance.", ] embed_ok = False @@ -131,15 +193,14 @@ def build_overview( embed_ok = service.probe_sidecar_embed() except Exception: embed_ok = True - if not status.sidecar.reachable: - if install_ready: + if not embed_ok: notes.append( - "openXYOS sidecar is offline — reconnecting the install-time local console." + "Optional openXYOS console is up but the embed origin is blocked (advanced preview)." ) - else: - notes.append("openXYOS sidecar is offline — start it to sync blueprints and approvals.") - elif not embed_ok: - notes.append("openXYOS livez is up but the embed origin is blocked (blank preview).") + elif service.explicit_sidecar_url(): + notes.append( + "Optional openXYOS sidecar is offline — in-host org storage and the growth loop still work." + ) return DualLoopOverview( enabled=status.enabled, sidecar_reachable=status.sidecar.reachable, @@ -154,4 +215,14 @@ def build_overview( openxyos=openxyos, last_loop=proof, notes=notes, + colleagues=colleague_rows, + experts=[ + { + "agent_id": row.agent_id, + "slug": row.slug, + "name": row.name, + } + for row in spawned + ], + org_surfaces=org_surfaces, ) diff --git a/src/octop/modules/org_os/service.py b/src/octop/modules/org_os/service.py index ef4dd719..1f666987 100644 --- a/src/octop/modules/org_os/service.py +++ b/src/octop/modules/org_os/service.py @@ -19,6 +19,13 @@ ORG_PLUGIN_ID = "org-os" DEFAULT_SIDECAR_URL = "http://127.0.0.1:3780" _CONFIG_SECTION = "org_os" +_SIDECAR_OPT_IN = {"1", "true", "yes", "on"} + + +def org_sidecar_wanted() -> bool: + """True only when the operator explicitly opts into the Node sidecar.""" + flag = os.environ.get("FREEOS_ORG_SIDECAR", "").strip().lower() + return flag in _SIDECAR_OPT_IN @dataclass(frozen=True) @@ -100,14 +107,27 @@ def is_enabled(self) -> bool: return False def sidecar_url(self) -> str: - env = os.environ.get("FREEOS_ORG_SIDECAR_URL", "").strip() - if env: - return normalize_sidecar_url(env) + explicit = self.explicit_sidecar_url() + return explicit or DEFAULT_SIDECAR_URL + + def explicit_sidecar_url(self) -> str: + """Return a configured sidecar origin, or empty when the Node stack is optional. + + The implicit ``http://127.0.0.1:3780`` default is display-only. Growth + loop / apply must not treat that fallback as a required control plane. + """ + for key in ("OPENXYOS_BASE_URL", "FREEOS_ORG_SIDECAR_URL"): + env = os.environ.get(key, "").strip() + if env: + return normalize_sidecar_url(env) section = self._section() raw = section.get("sidecar_url") if isinstance(raw, str) and raw.strip(): - return normalize_sidecar_url(raw) - return DEFAULT_SIDECAR_URL + cleaned = normalize_sidecar_url(raw) + if cleaned == DEFAULT_SIDECAR_URL and not org_sidecar_wanted(): + return "" + return cleaned + return "" def tenant_id(self) -> str: env = os.environ.get("FREEOS_ORG_TENANT_ID", "").strip() @@ -184,7 +204,13 @@ def set_enabled(self, enabled: bool, *, sidecar_url: str | None = None) -> None: _write_json(self.config_path, data) def probe_sidecar(self, timeout: float = 2.0) -> SidecarHealth: - url = self.sidecar_url() + url = self.explicit_sidecar_url() + if not url: + return SidecarHealth( + reachable=False, + url=DEFAULT_SIDECAR_URL, + detail="sidecar optional; not configured", + ) livez = f"{url}/api/health/livez" try: with httpx.Client(timeout=timeout, follow_redirects=True) as client: @@ -218,7 +244,9 @@ def probe_sidecar_embed(self, timeout: float = 2.0) -> bool: CORS whitelist answers those with HTTP 500 while livez (no Origin) still looks healthy — the Organization iframe stays white. """ - url = self.sidecar_url() + url = self.explicit_sidecar_url() + if not url: + return False origin = url.rstrip("/") try: with httpx.Client(timeout=timeout, follow_redirects=True) as client: @@ -234,19 +262,20 @@ def status(self) -> OrgModuleStatus: sidecar = self.probe_sidecar() enabled = self.is_enabled() notes = [ + "Organization capabilities run in the FreeOS Python host.", "Host identity stays in FreeOS (JWT users under the platform home).", - "openXYOS is the control plane; FreeOS is the data plane. " + "The optional openXYOS Node stack is for export/sync/advanced deploy, " + "not required for Organization or the growth loop.", "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`.", "Run the finished loop with `freeos org loop run`.", ] - if not sidecar.reachable and enabled: + if sidecar.reachable: + notes.append(f"Optional Node sidecar is reachable at {sidecar.url}.") + elif self.explicit_sidecar_url(): notes.append( - f"Sidecar is off. Start it with: bash scripts/run-org-sidecar.sh " - f"(listens on {sidecar.url})." + f"Optional sidecar at {sidecar.url} is offline; in-host org storage is used." ) return OrgModuleStatus( enabled=enabled, diff --git a/tests/unit/cli/test_version_cmd.py b/tests/unit/cli/test_version_cmd.py index 733a7758..d5f96173 100644 --- a/tests/unit/cli/test_version_cmd.py +++ b/tests/unit/cli/test_version_cmd.py @@ -13,11 +13,11 @@ _REPO_ROOT = Path(__file__).resolve().parents[3] -def test_product_version_is_001() -> None: +def test_product_version_is_002() -> None: with (_REPO_ROOT / "pyproject.toml").open("rb") as fh: declared = tomllib.load(fh)["project"]["version"] - assert declared == "0.0.1" - assert __version__ == "0.0.1" + assert declared == "0.0.2" + assert __version__ == "0.0.2" def test_version_prints_orca_version() -> None: @@ -25,7 +25,7 @@ def test_version_prints_orca_version() -> None: result = runner.invoke(cli, ["version"]) assert result.exit_code == 0 assert "octop" in result.output.lower() - assert "0.0.1" in result.output + assert "0.0.2" in result.output def test_version_in_help() -> None: @@ -39,4 +39,4 @@ def test_root_version_flag() -> None: result = runner.invoke(cli, ["-v"]) assert result.exit_code == 0 assert "octop" in result.output.lower() - assert "0.0.1" in result.output + assert "0.0.2" in result.output diff --git a/tests/unit/desktop/test_nsis_detail_encoding.py b/tests/unit/desktop/test_nsis_detail_encoding.py index 3f866763..76242c7b 100644 --- a/tests/unit/desktop/test_nsis_detail_encoding.py +++ b/tests/unit/desktop/test_nsis_detail_encoding.py @@ -102,11 +102,11 @@ def test_nsis_provision_does_not_exec_to_log() -> None: provision = nsh[ nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.openxyosFailDetail") ] - assert "nsExec::Exec $R4" in provision + assert "nsExec::Exec $R4" not in provision assert "ExecToLog" not in provision - assert provision.count("nsExec::Exec $R4") == 3 + assert "Abort" not in provision cmd = PROVISION_CMD.read_text(encoding="utf-8") assert ">nul" in cmd nsi = NSI.read_text(encoding="utf-8-sig") - assert "OPENXYOS_PROVISION_DETAIL" in nsi - assert "UTF-8" in nsi + assert "OPENXYOS_OPTIONAL_SKIP" in nsi + assert "Setup continues" in nsi diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index a58c8f7b..399b8142 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -10,8 +10,6 @@ DESKTOP_README = REPO / "desktop" / "README.md" ORG_PAGE = REPO / "dashboard" / "src" / "pages" / "Organization" / "index.tsx" ORG_BROWSER = REPO / "dashboard" / "src" / "pages" / "Organization" / "OrgMiniBrowser.tsx" -ORG_TABS = REPO / "dashboard" / "src" / "pages" / "Organization" / "orgBrowser.ts" -OPENURL_GO = REPO / "desktop" / "src" / "openurl.go" def _uninstall_section(text: str) -> str: @@ -147,20 +145,26 @@ def test_nsis_provisions_openxyos_runtime() -> None: assert "!insertmacro wails.provisionOpenXYOS" in nsh assert 'File "/oname=openxyos-runtime.zip"' in nsh assert r"$INSTDIR\openxyos" in nsh - assert r"$INSTDIR\openxyos-runtime" in nsh - assert r"$R6\FreeOS\openxyos" in nsh + assert "OPENXYOS_RUNTIME_ZIP_PRESENT" in nsh assert "ReadEnvStr $R6 LOCALAPPDATA" in nsh - assert "http://127.0.0.1:3780" in nsi assert "LangString OPENXYOS_WORKDIR ${LANG_SIMPCHINESE}" in nsi - assert "创建 openXYOS 工作目录" in nsi + assert "组织能力在 FreeOS 宿主内运行" in nsi assert "openXYOS 运行包" in nsi + present_gate = nsh[ + nsh.index("!ifndef OPENXYOS_RUNTIME_ZIP") : nsh.index("!ifndef REQUEST_EXECUTION_LEVEL") + ] + assert "!if /FileExists" not in present_gate + assert "!define OPENXYOS_RUNTIME_ZIP_PRESENT" not in present_gate task = (REPO / "desktop" / "src" / "build" / "windows" / "Taskfile.yml").read_text( encoding="utf-8" ) assert "stage:openxyos-runtime" in task + assert "maybe-stage:openxyos-runtime" in task + assert "SHIP_OPENXYOS_RUNTIME" in task assert "stage_openxyos_runtime.py" in task package = (REPO / "desktop" / "portable" / "package.sh").read_text(encoding="utf-8") assert "openXYOS frontend missing" in package + assert "SHIP_OPENXYOS_RUNTIME" in package def _nsis_filewrite_argc(line: str) -> int: @@ -205,7 +209,7 @@ def test_nsis_filewrite_is_exactly_two_args() -> None: def test_nsis_runs_openxyos_provisioner_subprocess() -> None: - """Parent copies a real provisioner and nsExecs it; livez failure aborts.""" + """Setup copies an optional payload and never aborts on livez or a missing zip.""" nsi = NSI.read_text(encoding="utf-8-sig") nsh = NSH.read_text(encoding="utf-8") provision = nsh[ @@ -218,36 +222,27 @@ def test_nsis_runs_openxyos_provisioner_subprocess() -> None: assert 'File "provision-openxyos.ps1"' in provision assert 'File "provision-openxyos.cmd"' in provision assert 'File "start-sidecar.ps1"' in provision - assert "nsExec::Exec $R4" in provision + assert "nsExec::Exec $R4" not in provision assert "nsExec::ExecToLog $R4" not in provision - assert "OPENXYOS_PROVISION_DETAIL" in provision - assert r"$INSTDIR\openxyos-runtime.zip" in provision - assert r"$R6\FreeOS\openxyos" in provision - # Cleanup after success lives in the provisioner, not NSIS (failed - # provision must keep the zip / openxyos-runtime folder for debug). - assert 'Delete "$INSTDIR\\openxyos-runtime.zip"' not in provision - assert 'RMDir /r "$INSTDIR\\openxyos-runtime"' not in provision - assert "Pop $0" in provision - assert "Abort" in provision - assert ".install-ready" in provision - assert "OPENXYOS_FAIL_START" in nsh - assert "OPENXYOS_FAIL_LIVEZ" in nsh - assert "start.log" in nsi - assert "LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE}" in nsi - assert "LangString OPENXYOS_PROVISION_DETAIL ${LANG_SIMPCHINESE}" in nsi - assert "provision.log(UTF-8)" in nsi - assert "子进程" in nsi - assert "OPENXYOS_PROBE_WARN" not in nsi - assert "安装将继续" not in nsi + assert "Abort" not in provision + assert ".install-ready" not in provision + assert "OPENXYOS_OPTIONAL_SKIP" in provision + assert "OPENXYOS_OPTIONAL_ABSENT" in provision + assert 'File "/oname=openxyos-runtime.zip"' in provision + assert "OPENXYOS_RUNTIME_ZIP_PRESENT is set but" in provision + assert "openxyos-runtime.zip missing" not in nsh + assert "安装继续" in nsi + assert "Setup continues" in nsi assert "开机自启" not in nsi assert "PersistOpenXYOS" not in nsi assert "PersistOpenXYOS" not in nsh assert "Call PersistOpenXYOS" not in nsh - assert "openxyos-runtime.zip missing" in nsh - assert "!error" in nsh workflow = (REPO / ".github" / "workflows" / "octop-desktop.yml").read_text(encoding="utf-8") - assert "org-sidecar/openxyos/dist/index.html" in workflow - assert "org-sidecar/openxyos/backend/server.ts" in workflow + assert 'SKIP_ORG_SIDECAR: "1"' in workflow + assert "SHIP_OPENXYOS_RUNTIME=0" in workflow + assert "must not embed org-sidecar" in workflow + assert "org-sidecar/openxyos/dist/index.html" not in workflow + assert "org-sidecar/openxyos/backend/server.ts" not in workflow def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: @@ -268,7 +263,23 @@ def test_desktop_readme_documents_uninstall_keep_vs_remove() -> None: assert "tar.exe" in text assert "README-only" in text assert "folder picker" in text - assert "install-time" in text.lower() or "during Setup" in text or "安装期" in text + assert "FREEOS_ORG_SIDECAR=1" in text + assert "SHIP_OPENXYOS_RUNTIME=1" in text + assert "in-host" in text.lower() or "in-host" in text + assert "does not extract" in text.lower() or "does not" in text.lower() + + +def test_desktop_metadata_is_single_runtime() -> None: + config = (REPO / "desktop" / "src" / "build" / "config.yml").read_text(encoding="utf-8") + info = (REPO / "desktop" / "src" / "build" / "windows" / "info.json").read_text( + encoding="utf-8" + ) + assert "in-host organization" in config + assert "FREEOS_ORG_SIDECAR=1" in config + assert "openXYOS sidecar" not in config + assert "Octop shell + openXYOS" not in config + assert "Octop shell + openXYOS" not in info + assert "in-host organization" in info def _filewrite_payloads(block: str) -> list[str]: @@ -324,38 +335,29 @@ def test_nsis_does_not_register_openxyos_logon_autostart() -> None: def test_organization_embeds_local_openxyos_url() -> None: page = ORG_PAGE.read_text(encoding="utf-8") browser = ORG_BROWSER.read_text(encoding="utf-8") - tabs = ORG_TABS.read_text(encoding="utf-8") - openurl = OPENURL_GO.read_text(encoding="utf-8") zh = (REPO / "dashboard" / "src" / "locales" / "zh.json").read_text(encoding="utf-8") + assert 'data-testid="org-native-workbench"' in page + assert 'data-testid="org-assemble"' in page + assert 'data-testid="org-pack"' in page + assert 'data-testid="org-loop"' in page + assert 'data-testid="org-colleagues"' in page + assert "org-mini-browser" not in page + assert "org-sidecar-gate" not in page + assert "org-restart-sidecar" not in page + assert "restartSidecar" not in page + assert "startSidecar" in page + assert 'data-testid="org-advanced-console"' in page assert "window.prompt" not in page assert "pickDesktopFolder" in page - assert "canPickDesktopFolder" in page assert "resolveOpenxyosSourceDest" in page assert 'data-testid="org-download-source"' in page - assert "void downloadSource()" in page - assert "http://127.0.0.1:3780" in tabs - assert "DEFAULT_ORG_URL" in page - assert " void startSidecar()}" not in page - assert "__FREEOS_ORG_OPEN_TAB__" in openurl - assert "installOrgPageWindowTrap" in page - assert "registerOrgBrowserHost" in page + assert "org-mini-browser" in browser org = zh[zh.index('"organization"') : zh.index('"systemSettings"')] assert "启动边车" not in org assert "重试启动" not in org - assert "正在打开本机 openXYOS" in org + assert "宿主内组织已就绪" in org def test_windows_folder_picker_emits_utf8_base64() -> None: diff --git a/tests/unit/test_org_loop.py b/tests/unit/test_org_loop.py index 0623e680..f09d8189 100644 --- a/tests/unit/test_org_loop.py +++ b/tests/unit/test_org_loop.py @@ -39,6 +39,25 @@ def _walk(store: LifecycleStore, slug: str, *states: str) -> None: transition(store, slug, state) +def test_growth_loop_does_not_require_node_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from octop.modules.org_os import sidecar_launch + + def _boom(*_args: object, **_kwargs: object) -> None: + raise AssertionError("growth loop must not start the Node sidecar") + + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR", raising=False) + monkeypatch.setattr(sidecar_launch, "ensure_sidecar", _boom) + proof = run_growth_loop(tmp_path, tenant_id="loop") + assert proof.ok is True + assert proof.remote_applied is False + assert (tmp_path / "openxyos-mirror" / "loop" / "employees.json").is_file() + assert (tmp_path / "org-agents" / "registry.json").is_file() + + def test_growth_loop_with_fixtures(tmp_path: Path) -> None: proof = run_growth_loop(tmp_path, tenant_id="loop") assert proof.ok is True diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py index 5bfe0f1b..95721011 100644 --- a/tests/unit/test_org_module.py +++ b/tests/unit/test_org_module.py @@ -139,6 +139,7 @@ def get(self, url: str) -> _Resp: assert url.endswith("/api/health/livez") return _Resp() + monkeypatch.setenv("FREEOS_ORG_SIDECAR_URL", "http://127.0.0.1:3780") monkeypatch.setattr(httpx, "Client", _Client) service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) health = service.probe_sidecar() @@ -169,6 +170,7 @@ def get(self, url: str, headers: dict[str, str] | None = None) -> _Resp: assert headers["Origin"] == "http://127.0.0.1:3780" return _Resp(500) + monkeypatch.setenv("FREEOS_ORG_SIDECAR_URL", "http://127.0.0.1:3780") monkeypatch.setattr(httpx, "Client", _Client) service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) assert service.probe_sidecar_embed() is False @@ -193,6 +195,7 @@ def __exit__(self, *args: object) -> None: def get(self, url: str, headers: dict[str, str] | None = None) -> _Resp: return _Resp() + monkeypatch.setenv("FREEOS_ORG_SIDECAR_URL", "http://127.0.0.1:3780") monkeypatch.setattr(httpx, "Client", _Client) service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) assert service.probe_sidecar_embed() is True @@ -247,7 +250,52 @@ def test_overview_reports_real_empty_counts(tmp_path: Path) -> None: assert payload["sidecar_embed_ok"] is False assert payload["install_ready"] is False assert payload["last_loop"] is None - assert any("FreeOS does the work" in note for note in payload["notes"]) + assert payload["runtime"] == "in_host" + assert payload["sidecar_optional"] is True + assert payload["colleagues"] == [] + assert payload["experts"] == [] + assert payload["org_surfaces"]["employees"] == 0 + assert any( + "in-host" in note.lower() or "FreeOS does the work" in note for note in payload["notes"] + ) + assert not any("start it to sync" in note for note in payload["notes"]) + + +def test_probe_sidecar_skips_default_3780_without_opt_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def _boom(*args: object, **kwargs: object) -> None: + raise AssertionError("must not probe implicit 3780") + + monkeypatch.delenv("FREEOS_ORG_SIDECAR_URL", raising=False) + monkeypatch.delenv("OPENXYOS_BASE_URL", raising=False) + monkeypatch.delenv("FREEOS_ORG_SIDECAR", raising=False) + monkeypatch.setattr(httpx, "Client", _boom) + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + health = service.probe_sidecar(timeout=0.2) + assert health.reachable is False + assert health.detail == "sidecar optional; not configured" + assert service.explicit_sidecar_url() == "" + + +def test_overview_lists_in_host_colleagues_after_assemble(tmp_path: Path) -> None: + from octop.modules.org_os.empower import assemble_from_blueprint, pack_to_openxyos + from octop.modules.org_os.overview import build_overview + + service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path) + assembled = assemble_from_blueprint(service) + packed = pack_to_openxyos(service) + snapshot = build_overview(service) + payload = snapshot.to_dict() + slugs = {row["slug"] for row in payload["colleagues"]} + assert slugs + assert slugs <= set(assembled["employees"]) + assert any(row["spawned"] for row in payload["colleagues"]) + assert payload["experts"] + assert payload["org_surfaces"]["employees"] >= 1 + assert packed["applied"]["mirrored"] is True + assert packed["applied"]["remote_applied"] is False + assert payload["sidecar_reachable"] is False def test_assemble_from_bundled_blueprint(tmp_path: Path) -> None: diff --git a/uv.lock b/uv.lock index c6fe6846..3fa657d0 100644 --- a/uv.lock +++ b/uv.lock @@ -2503,7 +2503,7 @@ wheels = [ [[package]] name = "octop" -version = "0.0.1" +version = "0.0.2" source = { editable = "." } dependencies = [ { name = "acme" }, diff --git a/website/README.md b/website/README.md index 8aa06f91..d18cf4c9 100644 --- a/website/README.md +++ b/website/README.md @@ -78,6 +78,6 @@ website/dist/ ## 内容边界 - 产品事实以仓库 `README.md`、`docs/architecture-integration.md`、`docs/asset-loop.md` 为准。 -- Windows 安装包指向 [v0.0.1 Release](https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.1)。 +- Windows 安装包指向 [v0.0.2 Release](https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.2)。 - 页脚按 `NOTICE` 致谢 Octop(MIT)与 openXYOS(Apache-2.0),不主张上游商标从属。 - 视觉节奏参考 XYAI Labs 公开站的章节编号与玻璃质感,文案仍是 FreeOS 产品自己的话。 diff --git a/website/package-lock.json b/website/package-lock.json index 4ac11001..c635921b 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "freeos-website", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "freeos-website", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/website/package.json b/website/package.json index 12fc746f..1a610339 100644 --- a/website/package.json +++ b/website/package.json @@ -1,7 +1,7 @@ { "name": "freeos-website", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "scripts": { "dev": "vite --host", diff --git a/website/src/copy.ts b/website/src/copy.ts index 33e4bace..a6c3b556 100644 --- a/website/src/copy.ts +++ b/website/src/copy.ts @@ -4,11 +4,11 @@ export const LINKS = { site: "https://freeos.cnxy.tech", github: "https://github.com/XYAIStudio/FreeOS", releases: "https://github.com/XYAIStudio/FreeOS/releases/latest", - releaseTag: "https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.1", + releaseTag: "https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.2", windowsAmd64: - "https://github.com/XYAIStudio/FreeOS/releases/download/v0.0.1/FreeOS-desktop-windows-amd64-0.0.1.exe", + "https://github.com/XYAIStudio/FreeOS/releases/download/v0.0.2/FreeOS-desktop-windows-amd64-0.0.2.exe", windowsArm64: - "https://github.com/XYAIStudio/FreeOS/releases/download/v0.0.1/FreeOS-desktop-windows-arm64-0.0.1.exe", + "https://github.com/XYAIStudio/FreeOS/releases/download/v0.0.2/FreeOS-desktop-windows-arm64-0.0.2.exe", openxyos: "https://github.com/XYAIStudio/openXYOS", openxyosSite: "https://openxyos.cnxy.tech/?lang=zh-CN", octopUpstream: "https://github.com/TencentCloud/Octop", @@ -42,7 +42,7 @@ export const copy = { manifesto: "不是再装一个聊天壳,而是让组织自己长出下一位同事。", brandAlt: "FreeOS 圆形标志:灰环、黄绿红勾玉、蓝色水滴", hero: { - eyebrow: "FreeOS · 开源 · 自托管 · v0.0.1", + eyebrow: "FreeOS · 开源 · 自托管 · v0.0.2", title: "FreeOS", headline: ["让个人智能伙伴", "长成可治理的组织系统"], promise: "懂你、帮你、陪你成长的智能伙伴", @@ -118,7 +118,7 @@ export const copy = { allReleases: "查看全部 Releases", stepsTitle: "四步开始", steps: [ - "打开 v0.0.1 Release,下载 amd64 或 arm64 安装包。", + "打开 v0.0.2 Release,下载 amd64 或 arm64 安装包。", "双击安装。程序会写入「程序文件」,并创建开始菜单与桌面快捷方式。", "打开 FreeOS。解压完成后直接进入可用会话——先聊,不必先登录。", "保存、导出或发布到账号时再注册。侧栏 Organization 默认打开。", @@ -174,7 +174,7 @@ export const copy = { cta: { title: "在自己的电脑上,养一支会成长的 AI 队伍", lead: "从 freeos.cnxy.tech 了解产品,到 GitHub 下载安装包,或从源码跑通 org loop。每一步都看得见、验得过、可决定。", - download: "下载 v0.0.1", + download: "下载 v0.0.2", github: "Star / Fork", openxyos: "了解 openXYOS", }, @@ -219,7 +219,7 @@ export const copy = { manifesto: "Not another chat shell — an organization that grows its next colleague.", brandAlt: "FreeOS circular mark: gray ring, yellow green red magatama, blue teardrop", hero: { - eyebrow: "FreeOS · Open source · Self-hosted · v0.0.1", + eyebrow: "FreeOS · Open source · Self-hosted · v0.0.2", title: "FreeOS", headline: ["Grow a personal partner", "into a governed organization OS"], promise: "Understands you, helps you, grows with you", @@ -295,7 +295,7 @@ export const copy = { allReleases: "All releases", stepsTitle: "Four steps", steps: [ - "Open the v0.0.1 release and download the amd64 or arm64 installer.", + "Open the v0.0.2 release and download the amd64 or arm64 installer.", "Run it. Files land in Program Files, with Start Menu and desktop shortcuts.", "Open FreeOS. After unpacking you land in a usable session — chat first, no login.", "Register only when you save, export, or publish to an account. Organization is on by default.", @@ -351,7 +351,7 @@ export const copy = { cta: { title: "Grow an AI workforce on your own computer", lead: "Read the product at freeos.cnxy.tech, download from GitHub, or run the org loop from source. Visible, verifiable, decidable at every step.", - download: "Download v0.0.1", + download: "Download v0.0.2", github: "Star / Fork", openxyos: "About openXYOS", },