+
+ {t("organization.heroTitle")}
+ {t("organization.heroStory")}
+
+
+ {overview?.enabled
+ ? t("organization.on")
+ : t("organization.off")}
+
+
+ {sidecarUp
+ ? t("organization.sidecarUp")
+ : t("organization.sidecarDown")}
+
+
+ {t("organization.lastSync")}: {lastSync}
+
+
+
+
+
+
+ {t("organization.toggleLabel")}
+ void toggle(checked)}
+ />
+
+
+ {!sidecarUp && (
+
+
+ {t("organization.startSidecarTitle")}
+
+
+ {t("organization.startSidecarBody")}
+
+
+ }
+ loading={busy === "sidecar"}
+ disabled={!overview?.start_available && !overview}
+ onClick={() => void startSidecar()}
+ >
+ {t("organization.startSidecarAction")}
+
+ {overview?.start_command && (
+ {overview.start_command}
+ )}
+
+
+ )}
+
+ {firstRun && (
+
+ {t("organization.emptyTitle")}
+ {t("organization.emptyBody")}
+
+ )}
+
+
+
+
+
+
{t("organization.dataPlane")}
+
FreeOS
+
+
+
+
+
+
+ {metric(overview?.freeos.employees)}
+
+
+ {t("organization.metricEmployees")}
+
+
+
+
+ {metric(overview?.freeos.org_skills)}
+
+
+ {t("organization.metricSkills")}
+
+
+
+
+ {metric(overview?.freeos.mcp)}
+
+
+ {t("organization.metricMcp")}
+
+
+
+
+ {metric(overview?.freeos.tasks)}
+
+
+ {t("organization.metricTasks")}
+
+
+
+
+
+
+
+
+ {t("organization.flowUp")}
+
+
+
+ {t("organization.flowDown")}
+
+
+
+
+
+
+
+
+ {t("organization.controlPlane")}
+
+
openXYOS
+
+
+
+
+
+
+ {metric(overview?.openxyos.modules)}
+
+
+ {t("organization.metricModules")}
+
+
+
+
+ {overview?.openxyos.governance
? t("organization.on")
: t("organization.off")}
-
-
- {sidecarUp
- ? t("organization.sidecarUp")
- : t("organization.sidecarDown")}
-
-
-
- {t("organization.bridgeHint")}
-
- {status && (
-
- {t("organization.home")}: {status.home}
-
- {t("organization.sidecar")}:{" "}
- {status.sidecar.url}
-
- )}
+
+
+ {t("organization.metricGovernance")}
+
+
+
+
+ {sidecarUp ? t("organization.sidecarUp") : "—"}
+
+
+ {t("organization.metricHealth")}
+
+
+
+
+ {overview?.openxyos.tenant_id || "—"}
+
+
+ {t("organization.metricTenant")}
+
+
-
-
-
- {status?.enabled && !sidecarUp && (
-
- {t("organization.startSidecarBody")}{" "}
- {status.start_command}
-
- }
- />
- )}
+
+
- {status?.notes?.length ? (
-
- {status.notes.map((note) => (
- {note}
- ))}
-
- }
- />
- ) : null}
-
-
-
- rowKey="key"
- size="small"
- pagination={false}
- dataSource={catalog}
- columns={[
- {
- title: t("organization.colKey"),
- dataIndex: "key",
- width: 140,
- render: (key: string) => {key},
- },
- {
- title: t("organization.colName"),
- render: (_, row) => (isZh ? row.label_zh : row.label),
- },
- {
- title: t("organization.colDesc"),
- render: (_, row) =>
- isZh ? row.description_zh : row.description,
- },
- {
- title: t("organization.colLock"),
- dataIndex: "locked",
- width: 100,
- render: (locked: boolean) =>
- locked ? (
- {t("organization.locked")}
- ) : (
- {t("organization.optional")}
- ),
- },
- ]}
- />
-
+
+
+ void packBack()}
+ >
+
+ {t("organization.packTitle")}
+ {t("organization.packBody")}
+ {t("organization.packAction")}
+
+ void runLoop()}
+ >
+
+ {t("organization.loopTitle")}
+ {t("organization.loopBody")}
+ {t("organization.loopAction")}
+
+
+
+
+ {t("organization.timelineTitle")}
+ {lastLoop ? (
+
+ -
+ {t("organization.timelineOk")}: {String(lastLoop.ok)}
+
+ {lastLoop.employees?.length ? (
+ -
+ {t("organization.timelineEmployees")}:{" "}
+ {lastLoop.employees.join(", ")}
+
+ ) : null}
+ {lastLoop.skills?.length ? (
+ -
+ {t("organization.timelineSkills")}: {lastLoop.skills.length}
+
+ ) : null}
+ -
+ {t("organization.timelineRemote")}:{" "}
+ {String(Boolean(lastLoop.remote_applied))}
+
+ -
+ {t("organization.timelineGovernance")}:{" "}
+ {String(Boolean(lastLoop.governance_blocked))}
+
+ {(lastLoop.notes ?? []).slice(0, 6).map((note) => (
+ - {note}
+ ))}
+
+ ) : (
+ {t("organization.timelineEmpty")}
+ )}
+ {lastActionNotes.length > 0 && (
+
+ {lastActionNotes.slice(0, 8).map((note) => (
+ - {note}
+ ))}
+
+ )}
+
- {showEmbed && (
-
+ {t("organization.catalogTitle")}
+
+ {catalog.map((row) => (
+
+
+ {isZh ? row.label_zh : row.label}{" "}
+ {row.locked ? t("organization.locked") : t("organization.optional")}
+
+
+ {isZh ? row.description_zh : row.description}
+
+
+ ))}
+
+
+
+ {embedUrl && (
+
+
+
{t("organization.embedTitle")}
{t("organization.openSidecar")}
- }
- >
+
-
+
)}
-
+
);
}
diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx
index bd497411..3b437928 100644
--- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx
+++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx
@@ -27,77 +27,44 @@ import styles from "./UpdateConfig.module.less";
/** Shell snippets shown in the manual upgrade guide (commands are locale-agnostic). */
const UPGRADE_GUIDE_CODE = {
- installerUnix: `curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash`,
- installerWin: `irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex`,
- cli: `octop update
-# or non-interactive:
-octop update --yes
-# pre-release (beta) requires --allow-prerelease:
-# octop update --allow-prerelease --yes`,
- pip: `pip install -U octop
-# optional extras, e.g. browser automation:
-# pip install -U "octop[browser]"`,
- source: `cd Octop
+ github: `https://github.com/XYAIStudio/FreeOS/releases`,
+ cli: `freeos update --check
+# or:
+octop update --check
+# apply a newer FreeOS desktop build:
+freeos update --yes`,
+ source: `git clone https://github.com/XYAIStudio/FreeOS
+# or, in an existing clone:
git pull
make build-frontend
-# or: cd dashboard && npm ci && npm run build && cd ..
-pip install -e .
-# with dev deps: make install / pip install -e ".[dev]"`,
- docker: `# Compose (from repo root; rebuild + recreate)
-docker compose -f docker/docker-compose.yml up -d --build
-
-# or rebuild the image then run with a persistent data volume
-bash docker/docker_build.sh
-docker run -d \\
- --name octop \\
- -p 8088:8088 \\
- -v octop-data:/data/.octop \\
- -e HOME=/data \\
- octop:latest`,
- restart: `# system service (systemd / launchd / Windows service)
-octop service restart
-
-# foreground process — stop the old process, then:
+uv sync`,
+ restart: `# Desktop: quit FreeOS, then open it again
+# (the shell applies pending-portable.zip on launch)
+
+# foreground process:
+freeos run
+# or:
octop run`,
} as const;
-type GuideMethodKey =
- | "ui"
- | "installer"
- | "cli"
- | "pip"
- | "source"
- | "docker"
- | "restart";
+type GuideMethodKey = "ui" | "github" | "cli" | "source" | "restart";
const GUIDE_METHOD_ORDER: GuideMethodKey[] = [
"ui",
- "installer",
+ "github",
"cli",
- "pip",
"source",
- "docker",
"restart",
];
function codeFor(key: GuideMethodKey): string | null {
switch (key) {
- case "installer":
- return [
- `# macOS / Linux`,
- UPGRADE_GUIDE_CODE.installerUnix,
- ``,
- `# Windows (PowerShell)`,
- UPGRADE_GUIDE_CODE.installerWin,
- ].join("\n");
+ case "github":
+ return UPGRADE_GUIDE_CODE.github;
case "cli":
return UPGRADE_GUIDE_CODE.cli;
- case "pip":
- return UPGRADE_GUIDE_CODE.pip;
case "source":
return UPGRADE_GUIDE_CODE.source;
- case "docker":
- return UPGRADE_GUIDE_CODE.docker;
case "restart":
return UPGRADE_GUIDE_CODE.restart;
default:
@@ -423,11 +390,11 @@ export default function UpdateConfig() {
)}
- {status?.source && status.source !== "pypi.org" && !status.error && (
+ {status?.source && !status.error && (
- {t("advancedSettings.update.mirrorSource", {
+ {t("advancedSettings.update.githubSource", {
source: status.source,
})}
@@ -528,9 +495,9 @@ export default function UpdateConfig() {
{t("advancedSettings.update.restartHint")}
- octop service restart
+ freeos run
/
- octop run
+ restart the FreeOS desktop app
diff --git a/dashboard/src/styles/appearanceStorage.test.ts b/dashboard/src/styles/appearanceStorage.test.ts
index 6a3e15d0..4029fe53 100644
--- a/dashboard/src/styles/appearanceStorage.test.ts
+++ b/dashboard/src/styles/appearanceStorage.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import {
loadAppearanceOnBoot,
+ OCTOP_ROSE_MIGRATED_KEY,
readStoredAppearance,
writeStoredAppearance,
} from "./appearanceStorage";
@@ -14,6 +15,7 @@ import {
afterEach(() => {
localStorage.removeItem(THEME_STORAGE_KEY);
localStorage.removeItem(LEGACY_PALETTE_STORAGE_KEY);
+ localStorage.removeItem(OCTOP_ROSE_MIGRATED_KEY);
});
describe("appearanceStorage", () => {
@@ -103,6 +105,37 @@ describe("appearanceStorage", () => {
});
});
+ it("migrates leftover Octop rose to FreeOS blue once on boot", () => {
+ localStorage.setItem(
+ THEME_STORAGE_KEY,
+ JSON.stringify({ preference: "dark", palette: "rose" }),
+ );
+
+ const appearance = loadAppearanceOnBoot();
+ expect(appearance.palette).toBe("freeos");
+ expect(appearance.preference).toBe("dark");
+ expect(localStorage.getItem(OCTOP_ROSE_MIGRATED_KEY)).toBe("1");
+ expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!).palette).toBe(
+ "freeos",
+ );
+ });
+
+ it("migrates legacy octop:ui-palette rose to FreeOS blue once", () => {
+ localStorage.setItem(THEME_STORAGE_KEY, "system");
+ localStorage.setItem(LEGACY_PALETTE_STORAGE_KEY, "rose");
+
+ expect(loadAppearanceOnBoot().palette).toBe("freeos");
+ expect(localStorage.getItem(OCTOP_ROSE_MIGRATED_KEY)).toBe("1");
+ });
+
+ it("keeps an explicit rose pick after the one-time Octop migration", () => {
+ localStorage.setItem(OCTOP_ROSE_MIGRATED_KEY, "1");
+ writeStoredAppearance({ preference: "light", palette: "rose" });
+
+ expect(readStoredAppearance().palette).toBe("rose");
+ expect(loadAppearanceOnBoot().palette).toBe("rose");
+ });
+
it("falls back safely on invalid JSON or unknown values", () => {
localStorage.setItem(THEME_STORAGE_KEY, "{not-json");
expect(readStoredAppearance().preference).toBe("system");
diff --git a/dashboard/src/styles/appearanceStorage.ts b/dashboard/src/styles/appearanceStorage.ts
index 3aebc765..f3ec525b 100644
--- a/dashboard/src/styles/appearanceStorage.ts
+++ b/dashboard/src/styles/appearanceStorage.ts
@@ -17,6 +17,9 @@ export type StoredAppearance = {
customColor?: string;
};
+/** One-time stamp: leftover Octop default ``rose`` was migrated to FreeOS blue. */
+export const OCTOP_ROSE_MIGRATED_KEY = "freeos:octop-rose-migrated";
+
const VALID_PREFERENCES: ThemePreference[] = ["system", "light", "dark"];
function isPreference(value: unknown): value is ThemePreference {
@@ -32,6 +35,14 @@ function isPalette(value: unknown): value is ThemePalette {
);
}
+function roseMigrationDone(): boolean {
+ return localStorage.getItem(OCTOP_ROSE_MIGRATED_KEY) === "1";
+}
+
+function markRoseMigrated(): void {
+ localStorage.setItem(OCTOP_ROSE_MIGRATED_KEY, "1");
+}
+
function readLegacyPalette(): ThemePalette {
const stored = localStorage.getItem(LEGACY_PALETTE_STORAGE_KEY);
if (isPalette(stored)) return stored;
@@ -100,9 +111,16 @@ export function writeStoredAppearance(appearance: StoredAppearance): void {
localStorage.removeItem(LEGACY_PALETTE_STORAGE_KEY);
}
-/** One-shot boot read + migrate for ThemeProvider initial state. */
+/** One-shot boot read + migrate leftover Octop rose for ThemeProvider. */
export function loadAppearanceOnBoot(): StoredAppearance {
const appearance = readStoredAppearance();
- writeStoredAppearance(appearance);
- return appearance;
+ const leftoverOctopRose =
+ appearance.palette === "rose" && !roseMigrationDone();
+ const next = leftoverOctopRose
+ ? { ...appearance, palette: DEFAULT_PALETTE }
+ : appearance;
+ writeStoredAppearance(next);
+ // Stamp even when the palette was already blue, so a later rose pick is kept.
+ markRoseMigrated();
+ return next;
}
diff --git a/dashboard/src/styles/themePalettes.test.ts b/dashboard/src/styles/themePalettes.test.ts
index 9b3baf51..dafdcb6d 100644
--- a/dashboard/src/styles/themePalettes.test.ts
+++ b/dashboard/src/styles/themePalettes.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
ANTD_BRAND_TOKENS,
+ DEFAULT_PALETTE,
+ FREEOS_BRAND_BLUE,
VALID_PALETTES,
brandPrimary,
} from "./themePalettes";
@@ -37,6 +39,18 @@ describe("theme palettes", () => {
]);
});
+ it("defaults to FreeOS blue, never Octop rose", () => {
+ expect(DEFAULT_PALETTE).toBe("freeos");
+ expect(DEFAULT_PALETTE).not.toBe("rose");
+ expect(brandPrimary(DEFAULT_PALETTE, false)).toBe(FREEOS_BRAND_BLUE);
+ expect(brandPrimary(DEFAULT_PALETTE, false).toUpperCase()).not.toBe(
+ "#E85D75",
+ );
+ expect(ANTD_BRAND_TOKENS[DEFAULT_PALETTE].light.colorPrimary).toBe(
+ "#0033FF",
+ );
+ });
+
it("uses FreeOS logo-center blue as the default brand tokens", () => {
expect(ANTD_BRAND_TOKENS.freeos.light.colorPrimary).toBe("#0033FF");
expect(ANTD_BRAND_TOKENS.freeos.light.colorPrimaryHover).toBe("#002EE6");
diff --git a/desktop/src/download.go b/desktop/src/download.go
index 514c1859..847c70c9 100644
--- a/desktop/src/download.go
+++ b/desktop/src/download.go
@@ -114,7 +114,10 @@ func shouldReplacePortable(root string) bool {
if err != nil {
bundledVersion = ""
}
- // Keep a newer in-app FreeOS portable (octop update) over an older zip.
+ if pendingPortableZip() != "" {
+ 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.
if currentVersion != "" && bundledVersion != "" &&
compareVersions(bundledVersion, currentVersion) < 0 &&
@@ -331,7 +334,36 @@ func versionPart(segment string) int {
return value
}
+func pendingPortableZip() string {
+ path := filepath.Join(productHome(), "updates", "pending-portable.zip")
+ if _, err := os.Stat(path); err != nil {
+ return ""
+ }
+ reader, err := zip.OpenReader(path)
+ if err != nil {
+ return ""
+ }
+ defer reader.Close()
+ if stampFromZip(reader.File) == "" {
+ return ""
+ }
+ return path
+}
+
+func clearPendingPortable() {
+ dir := filepath.Join(productHome(), "updates")
+ _ = os.Remove(filepath.Join(dir, "pending-portable.zip"))
+ _ = os.Remove(filepath.Join(dir, "pending.json"))
+}
+
func extractPortable(root string) error {
+ if pending := pendingPortableZip(); pending != "" {
+ if err := unzipGreen(pending, root); err != nil {
+ return err
+ }
+ clearPendingPortable()
+ return nil
+ }
if os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP") != "" {
zipPath, err := bundledPortableZip()
if err != nil {
@@ -350,6 +382,9 @@ func extractPortable(root string) error {
}
func bundledPortableZip() (string, error) {
+ if pending := pendingPortableZip(); pending != "" {
+ return pending, nil
+ }
if override := os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP"); override != "" {
if _, err := os.Stat(override); err != nil {
return "", fmt.Errorf("bundled portable package: %w", err)
@@ -364,11 +399,9 @@ func bundledPortableZip() (string, error) {
plat := greenPlat()
names := []string{
fmt.Sprintf("FreeOS-%s.zip", plat),
- fmt.Sprintf("Octop-%s.zip", plat),
}
globs := []string{
"FreeOS-portable-" + plat + "-*.zip",
- "Octop-portable-" + plat + "-*.zip",
}
searchDirs := []string{
dir,
diff --git a/desktop/src/download_test.go b/desktop/src/download_test.go
index c7108be7..05f6d8bd 100644
--- a/desktop/src/download_test.go
+++ b/desktop/src/download_test.go
@@ -204,6 +204,41 @@ func TestEnsurePortableKeepsNewerExistingRuntime(t *testing.T) {
}
}
+func TestEnsurePortableAppliesPendingFreeOSZip(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("OCTOP_HOME", home)
+ root := portableDir()
+
+ oldZip := filepath.Join(t.TempDir(), "old.zip")
+ writeTestGreenZip(t, oldZip, "0.0.1")
+ if err := unzipGreen(oldZip, root); err != nil {
+ t.Fatal(err)
+ }
+
+ pendingDir := filepath.Join(home, "updates")
+ if err := os.MkdirAll(pendingDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ pending := filepath.Join(pendingDir, "pending-portable.zip")
+ writeTestGreenZipWithStamp(t, pending, "0.0.2", "github-pending")
+ if err := os.WriteFile(filepath.Join(pendingDir, "pending.json"), []byte(`{"version":"0.0.2"}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := ensurePortable(LocaleZH, func(string) {}); err != nil {
+ t.Fatal(err)
+ }
+ if got := portableVersion(root); got != "0.0.2" {
+ t.Fatalf("portable version = %q, want 0.0.2", got)
+ }
+ if got := installedPortableStamp(root); got != "github-pending" {
+ t.Fatalf("stamp = %q, want github-pending", got)
+ }
+ if _, err := os.Stat(pending); !os.IsNotExist(err) {
+ t.Fatalf("pending zip should be consumed: %v", err)
+ }
+}
+
func TestEnsurePortableReplacesOctopLineageWithFreeOS(t *testing.T) {
home := t.TempDir()
t.Setenv("OCTOP_HOME", home)
diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py
index 97d7bee0..6d341812 100644
--- a/src/octop/api/openapi_meta.py
+++ b/src/octop/api/openapi_meta.py
@@ -142,7 +142,14 @@
"description": "Per-agent subagent definitions (agents/*.md) and bundled catalog install.",
},
{"name": "terminal", "description": "AI-assisted remote terminal sessions."},
- {"name": "update", "description": "In-place server update checks and apply flow."},
+ {
+ "name": "update",
+ "description": "In-place FreeOS update checks against XYAIStudio/FreeOS GitHub Releases.",
+ },
+ {
+ "name": "org-module",
+ "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, sidecar, and growth loop.",
+ },
{
"name": "browser",
"description": "Remote browser: env/install, harness sessions, live stream, record/replay.",
diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py
index e557c447..709d4809 100644
--- a/src/octop/api/routers/org_module.py
+++ b/src/octop/api/routers/org_module.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from contextlib import suppress
from typing import Any
@@ -11,8 +12,11 @@
from octop.api.deps import current_user, get_server, require_permission
from octop.infra.server import OctopServer
from octop.modules.org_os.catalog import OPENXYOS_MODULES
+from octop.modules.org_os.empower import assemble_from_blueprint, pack_to_openxyos
+from octop.modules.org_os.overview import build_overview
from octop.modules.org_os.proxy import identity_headers, proxy_request
from octop.modules.org_os.service import OrgModuleService, org_module_from_paths
+from octop.modules.org_os.sidecar_launch import find_sidecar_launcher, start_sidecar
router = APIRouter()
@@ -26,6 +30,39 @@ def _service(server: OctopServer) -> OrgModuleService:
return org_module_from_paths(server.paths)
+def _plane_counts(server: OctopServer, user: Any) -> dict[str, int]:
+ services = getattr(server, "services", None)
+ user_id = getattr(user, "id", None)
+ agents = 0
+ connectors = 0
+ cron_jobs = 0
+ skill_packages = 0
+ if services is not None:
+ try:
+ agents = len(services.agent_repo.list_all())
+ except Exception:
+ agents = 0
+ try:
+ if user_id is not None:
+ connectors = len(services.connector_repo.list_visible(int(user_id)))
+ except Exception:
+ connectors = 0
+ try:
+ cron_jobs = len(services.cron_repo.list_all())
+ except Exception:
+ cron_jobs = 0
+ try:
+ skill_packages = len(services.skill_package_repo.list_all())
+ except Exception:
+ skill_packages = 0
+ return {
+ "agents": agents,
+ "connectors": connectors,
+ "cron_jobs": cron_jobs,
+ "skill_packages": skill_packages,
+ }
+
+
@router.get("/status", summary="Organization module status")
async def org_module_status(
server: OctopServer = Depends(get_server),
@@ -34,6 +71,57 @@ async def org_module_status(
return _service(server).status().to_dict()
+@router.get("/overview", summary="FreeOS ↔ openXYOS dual-loop snapshot")
+async def org_module_overview(
+ server: OctopServer = Depends(get_server),
+ user: Any = Depends(current_user),
+) -> dict[str, Any]:
+ service = _service(server)
+ counts = _plane_counts(server, user)
+ return build_overview(
+ service,
+ agents=counts["agents"],
+ connectors=counts["connectors"],
+ cron_jobs=counts["cron_jobs"],
+ skill_packages=counts["skill_packages"],
+ start_available=find_sidecar_launcher() is not None,
+ ).to_dict()
+
+
+@router.post("/sidecar/start", summary="Start the bundled openXYOS sidecar")
+async def org_module_start_sidecar(
+ server: OctopServer = Depends(get_server),
+ _: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ service = _service(server)
+ if not service.is_enabled():
+ service.set_enabled(True)
+ started = await asyncio.to_thread(start_sidecar, service)
+ return started.to_dict()
+
+
+@router.post("/assemble", summary="Assemble digital employees from an openXYOS blueprint")
+async def org_module_assemble(
+ server: OctopServer = Depends(get_server),
+ _: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ service = _service(server)
+ if not service.is_enabled():
+ service.set_enabled(True)
+ return await asyncio.to_thread(assemble_from_blueprint, service)
+
+
+@router.post("/pack", summary="Pack FreeOS skills/MCP back to openXYOS")
+async def org_module_pack(
+ server: OctopServer = Depends(get_server),
+ _: Any = Depends(require_permission("plugins")),
+) -> dict[str, Any]:
+ service = _service(server)
+ if not service.is_enabled():
+ service.set_enabled(True)
+ return await asyncio.to_thread(pack_to_openxyos, service)
+
+
@router.get("/catalog", summary="openXYOS capability catalog")
async def org_module_catalog(
_user: Any = Depends(current_user),
diff --git a/src/octop/api/routers/update.py b/src/octop/api/routers/update.py
index 7cbec757..20761ab7 100644
--- a/src/octop/api/routers/update.py
+++ b/src/octop/api/routers/update.py
@@ -22,9 +22,10 @@
update_task,
)
from octop.infra.errors import ErrorCode, OctopError
+from octop.infra.setup.github_releases import SOURCE_LABEL
from octop.infra.setup.self_update import (
UpgradeResult,
- fetch_pypi_info,
+ fetch_release_info,
get_editable_path,
get_local_version,
green_packages_dir,
@@ -145,11 +146,11 @@ def _build_status(
if latest_any is None:
latest_any = latest
if latest_any is None and error is None:
- info = fetch_pypi_info()
+ info = fetch_release_info()
if info is not None:
latest_any = info.version
latest_stable = info.latest_stable
- source = info.source
+ source = info.source or SOURCE_LABEL
description = info.description
if latest_stable is None and latest_any and not is_prerelease(latest_any):
latest_stable = latest_any
@@ -188,7 +189,7 @@ async def update_status(
_: Any = Depends(current_user),
server: Any = Depends(get_server),
) -> dict[str, Any]:
- """Return last check result; re-probe PyPI when the server cache TTL expires."""
+ """Return last check result; re-probe GitHub Releases when the cache TTL expires."""
stable_only = _read_stable_only(server)
cached = get_cached_status()
if cached is not None:
@@ -206,22 +207,22 @@ async def check_for_updates(
server: Any = Depends(get_server),
) -> dict[str, Any]:
stable_only = _read_stable_only(server)
- pypi_info = await asyncio.to_thread(fetch_pypi_info)
- if pypi_info is None:
+ release = await asyncio.to_thread(fetch_release_info)
+ if release is None:
return await asyncio.to_thread(
_build_status,
latest=None,
- error="could not reach PyPI",
- error_code="pypi_unreachable",
+ error="could not reach GitHub Releases for XYAIStudio/FreeOS",
+ error_code="github_unreachable",
stable_only=stable_only,
include_prerelease=True,
)
return await asyncio.to_thread(
_build_status,
- latest_any=pypi_info.version,
- latest_stable=pypi_info.latest_stable,
- source=pypi_info.source,
- description=pypi_info.description,
+ latest_any=release.version,
+ latest_stable=release.latest_stable,
+ source=release.source or SOURCE_LABEL,
+ description=release.description,
stable_only=stable_only,
include_prerelease=True,
)
@@ -324,7 +325,7 @@ async def trigger_upgrade(
"editable installs must be upgraded manually (git pull / uv sync)",
)
stable_only = _read_stable_only(server)
- info = await asyncio.to_thread(fetch_pypi_info)
+ info = await asyncio.to_thread(fetch_release_info)
requested = body.version if body is not None else None
target = _resolve_upgrade_target(
requested=requested,
diff --git a/src/octop/cli/commands/update.py b/src/octop/cli/commands/update.py
index dd8512c5..03bf59f4 100644
--- a/src/octop/cli/commands/update.py
+++ b/src/octop/cli/commands/update.py
@@ -4,13 +4,13 @@
import click
+from octop.infra.setup.github_releases import GITHUB_RELEASES_HTML, SOURCE_LABEL
from octop.infra.setup.self_update import (
- fetch_pypi_info,
+ fetch_release_info,
get_editable_path,
get_local_version,
is_newer,
is_prerelease,
- resolve_venv_python,
run_upgrade,
)
@@ -27,13 +27,13 @@
help="Include and allow installing pre-releases (alpha / beta / rc / dev).",
)
def update(check: bool, yes: bool, verbose: bool, allow_prerelease: bool) -> None:
- """Check for and install a newer Octop release."""
+ """Check for and install a newer FreeOS desktop release from GitHub."""
current = get_local_version()
- info = fetch_pypi_info()
+ info = fetch_release_info()
click.echo(f"installed: {current}")
if info is None:
click.echo(
- "could not reach PyPI — check your network or proxy settings and retry",
+ f"could not reach {SOURCE_LABEL} — check your network and retry",
err=True,
)
raise SystemExit(1)
@@ -58,10 +58,10 @@ def update(check: bool, yes: bool, verbose: bool, allow_prerelease: bool) -> Non
raise SystemExit(1)
if not yes:
- click.confirm(f"Upgrade octop from {current} to {latest}?", abort=True)
+ click.confirm(f"Upgrade FreeOS from {current} to {latest}?", abort=True)
- venv_python = resolve_venv_python()
- click.echo(f"target python: {venv_python}")
+ click.echo(f"channel: {SOURCE_LABEL}")
+ click.echo(f"releases: {GITHUB_RELEASES_HTML}")
result = run_upgrade(
verbose=verbose,
allow_prerelease=is_prerelease(latest),
diff --git a/src/octop/infra/setup/github_releases.py b/src/octop/infra/setup/github_releases.py
new file mode 100644
index 00000000..8a4d52a1
--- /dev/null
+++ b/src/octop/infra/setup/github_releases.py
@@ -0,0 +1,246 @@
+"""FreeOS update channel — GitHub Releases for XYAIStudio/FreeOS only."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import platform
+import re
+import sys
+import urllib.error
+import urllib.request
+from dataclasses import dataclass, field
+from typing import Any
+from urllib.parse import urlparse
+
+logger = logging.getLogger(__name__)
+
+GITHUB_OWNER = "XYAIStudio"
+GITHUB_REPO = "FreeOS"
+GITHUB_RELEASES_API = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases"
+GITHUB_RELEASES_HTML = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases"
+SOURCE_LABEL = f"github.com/{GITHUB_OWNER}/{GITHUB_REPO}"
+
+_FORBIDDEN_HOSTS = (
+ "pypi.org",
+ "pypi.python.org",
+ "files.pythonhosted.org",
+ "mirrors.cloud.tencent.com",
+ "mirrors.aliyun.com",
+ "pypi.tuna.tsinghua.edu.cn",
+ "mirrors.ustc.edu.cn",
+ "finnie-1258344699.cos.ap-guangzhou.myqcloud.com",
+ "octop-1258344699.cos.ap-guangzhou.myqcloud.com",
+)
+
+_TAG_RE = re.compile(r"^v?(?P.+)$", re.IGNORECASE)
+
+
+@dataclass(frozen=True)
+class ReleaseAsset:
+ name: str
+ url: str
+ size: int = 0
+ content_type: str = ""
+
+ def is_freeos_desktop(self) -> bool:
+ lower = self.name.lower()
+ if "octop" in lower and "freeos" not in lower:
+ return False
+ return lower.startswith("freeos-portable-") or lower.startswith("freeos-desktop-")
+
+ def is_portable_zip(self) -> bool:
+ lower = self.name.lower()
+ return lower.startswith("freeos-portable-") and lower.endswith(".zip")
+
+
+@dataclass
+class GitHubReleaseInfo:
+ """Newest FreeOS GitHub Release that publishes desktop/portable artifacts."""
+
+ version: str
+ """Newest version including pre-releases (``latest_any``)."""
+
+ description: str | None = None
+ source: str | None = SOURCE_LABEL
+ latest_stable: str | None = None
+ html_url: str | None = GITHUB_RELEASES_HTML
+ assets: list[ReleaseAsset] = field(default_factory=list)
+
+
+def assert_allowed_update_url(url: str) -> str:
+ """Reject Octop / PyPI / TencentCloud update hosts."""
+ cleaned = (url or "").strip()
+ parsed = urlparse(cleaned)
+ host = (parsed.hostname or "").lower()
+ if not host:
+ raise ValueError("update URL is missing a host")
+ if any(host == banned or host.endswith(f".{banned}") for banned in _FORBIDDEN_HOSTS):
+ raise ValueError(f"refusing Octop/PyPI/Tencent update host: {host}")
+ if "pypi" in host:
+ raise ValueError(f"refusing PyPI-like update host: {host}")
+ return cleaned
+
+
+def tag_to_version(tag: str) -> str:
+ raw = (tag or "").strip()
+ match = _TAG_RE.match(raw)
+ if match is None:
+ return raw.lstrip("vV")
+ return match.group("version").lstrip("vV") or raw
+
+
+def desktop_plat() -> str:
+ """Match desktop/Go ``greenPlat``: ``windows-amd64``, ``darwin-arm64``, …"""
+ system = sys.platform
+ if system == "win32":
+ os_name = "windows"
+ elif system == "darwin":
+ os_name = "darwin"
+ else:
+ os_name = "linux"
+ machine = platform.machine().lower()
+ arch = "arm64" if machine in {"arm64", "aarch64"} else "amd64"
+ return f"{os_name}-{arch}"
+
+
+def _asset_from_payload(raw: dict[str, Any]) -> ReleaseAsset | None:
+ name = str(raw.get("name") or "").strip()
+ url = str(raw.get("browser_download_url") or raw.get("url") or "").strip()
+ if not name or not url:
+ return None
+ try:
+ assert_allowed_update_url(url)
+ except ValueError:
+ logger.warning("skipping forbidden release asset %s (%s)", name, url)
+ return None
+ asset = ReleaseAsset(
+ name=name,
+ url=url,
+ size=int(raw.get("size") or 0),
+ content_type=str(raw.get("content_type") or ""),
+ )
+ if not asset.is_freeos_desktop():
+ return None
+ return asset
+
+
+def parse_github_releases(payload: Any) -> list[tuple[str, GitHubReleaseInfo]]:
+ """Return ``(version, info)`` rows that have at least one FreeOS desktop asset."""
+ if not isinstance(payload, list):
+ return []
+ rows: list[tuple[str, GitHubReleaseInfo]] = []
+ for item in payload:
+ if not isinstance(item, dict) or item.get("draft"):
+ continue
+ version = tag_to_version(str(item.get("tag_name") or ""))
+ if not version:
+ continue
+ assets = [
+ asset
+ for raw in (item.get("assets") or [])
+ if isinstance(raw, dict)
+ for asset in [_asset_from_payload(raw)]
+ if asset is not None
+ ]
+ if not assets:
+ continue
+ rows.append(
+ (
+ version,
+ GitHubReleaseInfo(
+ version=version,
+ description=str(item.get("body") or "") or None,
+ source=SOURCE_LABEL,
+ html_url=str(item.get("html_url") or GITHUB_RELEASES_HTML),
+ assets=assets,
+ ),
+ )
+ )
+ return rows
+
+
+def pick_release_versions(
+ rows: list[tuple[str, GitHubReleaseInfo]],
+ *,
+ is_prerelease: Any,
+ parse_version: Any,
+) -> tuple[GitHubReleaseInfo | None, GitHubReleaseInfo | None]:
+ """Return ``(latest_any, latest_stable)`` using the caller's PEP 440 helpers."""
+ if not rows:
+ return None, None
+ latest_any_ver = max((ver for ver, _ in rows), key=parse_version)
+ latest_any = next(info for ver, info in rows if ver == latest_any_ver)
+ stables = [(ver, info) for ver, info in rows if not is_prerelease(ver)]
+ latest_stable = None
+ if stables:
+ latest_stable_ver = max((ver for ver, _ in stables), key=parse_version)
+ latest_stable = next(info for ver, info in stables if ver == latest_stable_ver)
+ latest_any.latest_stable = latest_stable_ver
+ latest_stable.latest_stable = latest_stable_ver
+ else:
+ latest_any.latest_stable = None
+ latest_any.version = latest_any_ver
+ return latest_any, latest_stable
+
+
+def pick_portable_asset(
+ assets: list[ReleaseAsset],
+ *,
+ plat: str | None = None,
+ version: str | None = None,
+) -> ReleaseAsset | None:
+ """Prefer ``FreeOS-portable--.zip`` for the running arch."""
+ wanted = plat or desktop_plat()
+ portable = [asset for asset in assets if asset.is_portable_zip()]
+ if not portable:
+ return None
+
+ def _score(asset: ReleaseAsset) -> tuple[int, int, str]:
+ name = asset.name.lower()
+ exact_plat = 1 if wanted in name else 0
+ exact_ver = 1 if version and version.lower() in name else 0
+ return (exact_plat, exact_ver, name)
+
+ ranked = sorted(portable, key=_score, reverse=True)
+ best = ranked[0]
+ if wanted not in best.name.lower():
+ return None
+ return best
+
+
+def _github_headers() -> dict[str, str]:
+ headers = {
+ "Accept": "application/vnd.github+json",
+ "User-Agent": "FreeOS-updater/1.0",
+ "X-GitHub-Api-Version": "2022-11-28",
+ }
+ token = (os.environ.get("FREEOS_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN") or "").strip()
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return headers
+
+
+def fetch_github_releases(
+ timeout: int = 10,
+ *,
+ urlopen: Any = None,
+ is_prerelease: Any,
+ parse_version: Any,
+) -> GitHubReleaseInfo | None:
+ """GET the public Releases API. Returns None on network/parse failure."""
+ assert_allowed_update_url(GITHUB_RELEASES_API)
+ opener = urlopen or urllib.request.urlopen
+ try:
+ req = urllib.request.Request(GITHUB_RELEASES_API, headers=_github_headers())
+ with opener(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ rows = parse_github_releases(data)
+ latest_any, _latest_stable = pick_release_versions(
+ rows, is_prerelease=is_prerelease, parse_version=parse_version
+ )
+ return latest_any
+ except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError, ValueError) as exc:
+ logger.warning("failed to fetch FreeOS GitHub Releases: %s", exc)
+ return None
diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py
index 95defebc..9e69d2c7 100644
--- a/src/octop/infra/setup/self_update.py
+++ b/src/octop/infra/setup/self_update.py
@@ -16,20 +16,23 @@
from pathlib import Path
from typing import Any
+from octop.infra.setup.github_releases import (
+ GITHUB_RELEASES_HTML,
+ SOURCE_LABEL,
+ GitHubReleaseInfo,
+ assert_allowed_update_url,
+ desktop_plat,
+ fetch_github_releases,
+ pick_portable_asset,
+)
from octop.infra.utils.paths import PathLayout
logger = logging.getLogger(__name__)
_PACKAGE_NAME = "octop"
-_PYPI_URL = f"https://pypi.org/pypi/{_PACKAGE_NAME}/json"
_GREEN_PACKAGES_ENV = "OCTOP_GREEN_PACKAGES"
-
-_MIRRORS = [
- "https://mirrors.cloud.tencent.com/pypi/simple",
- "https://mirrors.aliyun.com/pypi/simple",
- "https://pypi.tuna.tsinghua.edu.cn/simple",
- "https://mirrors.ustc.edu.cn/pypi/simple",
-]
+PENDING_PORTABLE_NAME = "pending-portable.zip"
+PENDING_META_NAME = "pending.json"
_COMMON_UV_PATHS = [
os.path.expanduser("~/.local/bin/uv"),
@@ -108,19 +111,30 @@ def get_local_version() -> str:
return "0.0.0"
-@dataclass
-class PyPIInfo:
- version: str
- """Newest version on the index, including pre-releases (``latest_any``)."""
+# Backward-compatible name used by older tests / imports.
+PyPIInfo = GitHubReleaseInfo
+
+
+def updates_dir(home: Path | None = None) -> Path:
+ root = home or PathLayout.from_env().root
+ dest = root / "updates"
+ dest.mkdir(parents=True, exist_ok=True)
+ return dest
- description: str | None = None
- """Package long description."""
- source: str | None = None
- """Label of the source that served this payload (e.g. ``pypi.org``)."""
+def pending_portable_zip(home: Path | None = None) -> Path:
+ return updates_dir(home) / PENDING_PORTABLE_NAME
- latest_stable: str | None = None
- """Newest non-pre-release version, or None if every release is a pre-release."""
+
+def pending_portable_meta(home: Path | None = None) -> Path:
+ return updates_dir(home) / PENDING_META_NAME
+
+
+def is_desktop_install() -> bool:
+ if green_packages_dir() is not None:
+ return True
+ raw = (os.environ.get("OCTOP_DESKTOP") or os.environ.get("FREEOS_DESKTOP") or "").strip()
+ return raw.lower() in {"1", "true", "yes", "on"}
def fetch_latest_pypi_version(
@@ -128,7 +142,8 @@ def fetch_latest_pypi_version(
*,
include_prerelease: bool = False,
) -> str | None:
- info = fetch_pypi_info(timeout=timeout)
+ """Deprecated alias — FreeOS reads GitHub Releases, never PyPI."""
+ info = fetch_release_info(timeout=timeout)
if info is None:
return None
if include_prerelease:
@@ -136,26 +151,6 @@ def fetch_latest_pypi_version(
return info.latest_stable
-def _usable_release_versions(data: dict[str, Any]) -> list[str]:
- """Return PyPI ``releases`` keys that are not fully yanked."""
- releases = data.get("releases")
- if not isinstance(releases, dict):
- return []
- versions: list[str] = []
- for raw_ver, files in releases.items():
- ver = str(raw_ver)
- if not ver:
- continue
- if (
- isinstance(files, list)
- and files
- and all(isinstance(item, dict) and item.get("yanked") for item in files)
- ):
- continue
- versions.append(ver)
- return versions
-
-
def pick_latest_versions(versions: list[str]) -> tuple[str | None, str | None]:
"""Return ``(latest_any, latest_stable)`` using PEP 440 order."""
usable = [ver for ver in versions if ver]
@@ -167,38 +162,18 @@ def pick_latest_versions(versions: list[str]) -> tuple[str | None, str | None]:
return latest_any, latest_stable
-def fetch_pypi_info(timeout: int = 10) -> PyPIInfo | None:
- """Fetch version and long description from the PyPI JSON API.
+def fetch_release_info(timeout: int = 10) -> GitHubReleaseInfo | None:
+ """Fetch the newest FreeOS desktop release from GitHub Releases."""
+ return fetch_github_releases(
+ timeout=timeout,
+ is_prerelease=is_prerelease,
+ parse_version=parse_version,
+ )
- ``version`` is the newest release including pre-releases. ``latest_stable``
- is the newest non-pre-release (None when the index only has pre-releases).
- Returns None on any network or parse failure.
- """
- try:
- req = urllib.request.Request(
- _PYPI_URL,
- headers={"User-Agent": f"{_PACKAGE_NAME}-updater/1.0"},
- )
- with urllib.request.urlopen(req, timeout=timeout) as resp:
- data = json.loads(resp.read().decode("utf-8"))
- info = data["info"]
- versions = _usable_release_versions(data)
- info_version = str(info["version"])
- if info_version and info_version not in versions:
- versions.append(info_version)
- latest_any, latest_stable = pick_latest_versions(versions)
- if latest_any is None:
- latest_any = info_version
- latest_stable = info_version if not is_prerelease(info_version) else None
- return PyPIInfo(
- version=latest_any,
- latest_stable=latest_stable,
- description=info.get("description"),
- source="pypi.org",
- )
- except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as exc:
- logger.warning("failed to fetch PyPI info: %s", exc)
- return None
+
+def fetch_pypi_info(timeout: int = 10) -> GitHubReleaseInfo | None:
+ """Deprecated name kept so callers/tests can patch one entry point."""
+ return fetch_release_info(timeout=timeout)
def parse_changelog_for_version(description: str | None, version: str) -> str | None:
@@ -358,10 +333,11 @@ def find_pip_in_venv(python_exe: str) -> str | None:
def package_requirement(version: str | None = None) -> str:
- """Return ``octop`` or a pinned ``octop==`` spec."""
- if version:
- return f"{_PACKAGE_NAME}=={version}"
- return _PACKAGE_NAME
+ """Deprecated PyPI spec — FreeOS no longer installs ``octop`` from indexes."""
+ raise RuntimeError(
+ "FreeOS does not install the upstream octop package. "
+ f"Download desktop builds from {GITHUB_RELEASES_HTML}"
+ )
def append_prerelease_flags(
@@ -507,110 +483,59 @@ def _verify_fpk_upgrade(
)
-def _run_fpk_upgrade(
- site_packages: str,
+def download_release_asset(
+ url: str,
+ dest: Path,
*,
- verbose: bool = False,
- allow_prerelease: bool = False,
- version: str | None = None,
-) -> UpgradeResult:
- """FnOS FPK 部署下的在线升级:把新版安装到 launcher 实际加载的打包目录。
-
- launcher 通过 PYTHONPATH 从应用中心托管的打包 site-packages 加载 octop,
- 在线安装到系统 Python 永远不会被加载(重启后仍是旧版)。本函数把新版
- 安装到该打包目录本身,重启服务后即加载新版,升级真正生效。
+ timeout: int = 120,
+ urlopen: Any = None,
+) -> None:
+ """Download a GitHub release asset to *dest* (forbidden hosts rejected)."""
+ assert_allowed_update_url(url)
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ opener = urlopen or urllib.request.urlopen
+ req = urllib.request.Request(
+ url,
+ headers={
+ "User-Agent": "FreeOS-updater/1.0",
+ "Accept": "application/octet-stream",
+ },
+ )
+ with opener(req, timeout=timeout) as resp, dest.open("wb") as out:
+ shutil.copyfileobj(resp, out)
- 与普通部署不同,FPK 首次在线升级需要从零解析并下载完整依赖树
- (octop 依赖 orcakit-harness-agent 等大包),故超时显著放宽;且某镜像
- 可能滞后(装到同版本旧版),此时继续尝试下一个镜像,最后以 pypi.org
- 兜底,避免「镜像有货但版本不新」导致升级假成功。
- """
- if not os.path.isdir(site_packages):
- return UpgradeResult(
- success=False,
- error=f"FPK site-packages 目录不存在:{site_packages}",
- )
- local_ver = get_local_version()
- mirror_errors: list[str] = []
- per_mirror_timeout = 900 # FPK 首次升级需下载完整依赖树,180s 不够
-
- def _run_install(cmd: list[str], label: str) -> tuple[int | None, str]:
- logger.debug("running %s: %s", label, " ".join(cmd))
- try:
- # NOCA:DangerousSubprocessUseAudit(argv list with shell=False; installer paths and mirrors are trusted)
- result = subprocess.run(
- cmd,
- check=False,
- capture_output=not verbose,
- text=True,
- timeout=per_mirror_timeout,
- )
- except subprocess.TimeoutExpired:
- return None, f"timed out after {per_mirror_timeout}s"
- if result.returncode == 0:
- return 0, ""
- snippet = (result.stderr or result.stdout or "")[:300]
- return result.returncode, snippet
-
- python_exe = sys.executable
- installer = detect_installer() # uv 优先:pip 对 orcakit-harness-agent[all] 依赖树解析会卡死
-
- def _build_cmd(index_url: str = "") -> list[str]:
- requirement = package_requirement(version)
- if installer == "uv":
- cmd = [
- find_uv_executable(),
- "pip",
- "install",
- "--python",
- python_exe,
- "--target",
- site_packages,
- "--upgrade-package",
- _PACKAGE_NAME,
- ]
- if index_url:
- cmd.extend(["--index-url", index_url])
- append_prerelease_flags(cmd, installer, allow_prerelease=allow_prerelease)
- cmd.append(requirement)
- return cmd
- cmd = [
- python_exe,
- "-m",
- "pip",
- "install",
- "--upgrade",
- "--upgrade-strategy",
- "only-if-needed",
- "--target",
- site_packages,
- ]
- if index_url:
- cmd.extend(["-i", index_url])
- append_prerelease_flags(cmd, installer, allow_prerelease=allow_prerelease)
- cmd.append(requirement)
- return cmd
- for mirror in _MIRRORS:
- rc, err_snippet = _run_install(_build_cmd(mirror), mirror)
- if rc != 0:
- mirror_errors.append(f"{mirror}: {err_snippet}")
- continue
- res = _verify_fpk_upgrade(local_ver, site_packages, python_exe, mirror_errors)
- if res.success:
- return res
- # 镜像装到了同版本/旧版(同步滞后):继续尝试下一个镜像
- mirror_errors.append(f"{mirror}: {res.error or 'version unchanged'}")
-
- rc, err_snippet = _run_install(_build_cmd(), "pypi.org")
- if rc != 0:
- mirror_errors.append(f"pypi.org: {err_snippet or 'unknown error'}")
- return UpgradeResult(
- success=False,
- error="upgrade failed on all mirrors",
- mirror_errors=mirror_errors,
+def stage_desktop_portable(
+ asset_url: str,
+ *,
+ version: str,
+ plat: str,
+ home: Path | None = None,
+ urlopen: Any = None,
+) -> Path:
+ """Write a pending FreeOS portable zip for the desktop shell to apply on restart."""
+ home_root = home or PathLayout.from_env().root
+ zip_path = pending_portable_zip(home_root)
+ tmp = zip_path.with_suffix(".part")
+ if tmp.exists():
+ tmp.unlink()
+ download_release_asset(asset_url, tmp, urlopen=urlopen)
+ tmp.replace(zip_path)
+ pending_portable_meta(home_root).write_text(
+ json.dumps(
+ {
+ "version": version,
+ "plat": plat,
+ "source": SOURCE_LABEL,
+ "html_url": GITHUB_RELEASES_HTML,
+ "zip": str(zip_path),
+ },
+ indent=2,
)
- return _verify_fpk_upgrade(local_ver, site_packages, python_exe, mirror_errors)
+ + "\n",
+ encoding="utf-8",
+ )
+ return zip_path
def run_upgrade(
@@ -619,114 +544,57 @@ def run_upgrade(
allow_prerelease: bool = False,
version: str | None = None,
) -> UpgradeResult:
- # [FPK] FnOS FPK 部署:launcher 通过 PYTHONPATH 从应用中心托管的打包
- # site-packages 加载 octop,在线安装到系统 Python 永远不会被加载(重启
- # 无效)。launcher 导出 OCTOP_FPK_SITE_PACKAGES 指向该打包目录,升级即
- # 安装到此目录并提示重启服务生效——升级真正可用,而非禁止升级。
- _fpk_site = os.environ.get("OCTOP_FPK_SITE_PACKAGES", "").strip()
- if _fpk_site:
- return _run_fpk_upgrade(
- _fpk_site,
- verbose=verbose,
- allow_prerelease=allow_prerelease,
- version=version,
- )
- installer = detect_installer()
- venv_python = resolve_venv_python()
- local_ver = get_local_version()
- mirror_errors: list[str] = []
- per_mirror_timeout = 180
-
- def _run_install(cmd: list[str], label: str) -> tuple[int | None, str]:
- logger.debug("running %s: %s", label, " ".join(cmd))
- try:
- # NOCA:DangerousSubprocessUseAudit(argv list with shell=False; installer paths and mirrors are trusted)
- result = subprocess.run(
- cmd,
- check=False,
- capture_output=not verbose,
- text=True,
- timeout=per_mirror_timeout,
- )
- except subprocess.TimeoutExpired:
- return None, f"timed out after {per_mirror_timeout}s"
- if result.returncode == 0:
- return 0, ""
- snippet = (result.stderr or result.stdout or "")[:300]
- return result.returncode, snippet
+ """Download the matching FreeOS desktop/portable asset from GitHub Releases.
- for mirror in _MIRRORS:
- cmd = build_upgrade_command(
- installer,
- venv_python,
- index_url=mirror,
- allow_prerelease=allow_prerelease,
- version=version,
- )
- if cmd is None:
- return UpgradeResult(
- success=False,
- error="pip is not available for the Octop virtual environment.",
- mirror_errors=mirror_errors,
- )
- rc, err_snippet = _run_install(cmd, mirror)
- if rc == 0:
- return _verify_upgrade(local_ver, venv_python, mirror_errors)
- mirror_errors.append(f"{mirror}: {err_snippet}")
-
- cmd = build_upgrade_command(
- installer,
- venv_python,
- allow_prerelease=allow_prerelease,
- version=version,
- )
- if cmd is None:
+ Desktop installs stage ``{home}/updates/pending-portable.zip``. The Wails
+ shell applies it on the next launch (``FREEOS_STAMP`` preserved). Non-desktop
+ installs are pointed at the public Releases page — we never ``pip install
+ octop`` from PyPI or Tencent mirrors.
+ """
+ del verbose
+ info = fetch_release_info()
+ if info is None:
return UpgradeResult(
success=False,
- error="pip is not available for the Octop virtual environment.",
- mirror_errors=mirror_errors,
+ error=f"could not reach {SOURCE_LABEL}",
)
- rc, err_snippet = _run_install(cmd, "pypi.org")
- if rc != 0:
- mirror_errors.append(f"pypi.org: {err_snippet or 'unknown error'}")
+ target = (version or "").strip() or (
+ info.version if allow_prerelease else (info.latest_stable or info.version)
+ )
+ if not target:
+ return UpgradeResult(success=False, error="no FreeOS release on GitHub")
+ if not allow_prerelease and is_prerelease(target):
return UpgradeResult(
success=False,
- error="upgrade failed on all mirrors",
- mirror_errors=mirror_errors,
+ error=f"{target} is a pre-release; pass --allow-prerelease to install it",
)
- return _verify_upgrade(local_ver, venv_python, mirror_errors)
-
-
-def _verify_upgrade(
- local_ver: str,
- venv_python: str,
- mirror_errors: list[str],
-) -> UpgradeResult:
- actual_ver: str | None = None
- for attempt in range(3):
- actual_ver = get_installed_version(venv_python)
- if actual_ver and actual_ver != local_ver:
- break
- if attempt < 2:
- time.sleep(0.5)
-
- if actual_ver and is_newer(actual_ver, local_ver):
- return UpgradeResult(
- success=True,
- message=f"upgraded to {actual_ver}",
- installed_version=actual_ver,
- mirror_errors=mirror_errors,
- )
- if actual_ver == local_ver:
+ plat = desktop_plat()
+ asset = pick_portable_asset(info.assets, plat=plat, version=target)
+ if asset is None:
+ if is_desktop_install():
+ return UpgradeResult(
+ success=False,
+ error=(
+ f"no FreeOS-portable-{plat}-*.zip on {SOURCE_LABEL} "
+ f"for {target}. Download from {GITHUB_RELEASES_HTML}"
+ ),
+ )
return UpgradeResult(
- success=True,
- message=f"installer finished but version is still {actual_ver}",
- installed_version=actual_ver,
- mirror_errors=mirror_errors,
+ success=False,
+ error=(
+ "FreeOS no longer upgrades from PyPI or Tencent Cloud mirrors. "
+ f"Install a FreeOS desktop build from {GITHUB_RELEASES_HTML}"
+ ),
)
+ try:
+ staged = stage_desktop_portable(asset.url, version=target, plat=plat)
+ except Exception as exc:
+ return UpgradeResult(success=False, error=str(exc) or type(exc).__name__)
return UpgradeResult(
success=True,
- message="upgrade completed",
- installed_version=actual_ver,
- mirror_errors=mirror_errors,
+ message=(
+ f"staged FreeOS {target} portable ({asset.name}) at {staged}; "
+ "restart the desktop app to apply it"
+ ),
+ installed_version=target,
)
diff --git a/src/octop/modules/org_os/empower.py b/src/octop/modules/org_os/empower.py
new file mode 100644
index 00000000..df07dc5b
--- /dev/null
+++ b/src/octop/modules/org_os/empower.py
@@ -0,0 +1,83 @@
+"""One-click FreeOS ↔ openXYOS empowerment actions."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from octop.modules.org_os.apply.apply import apply_asset_pack
+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.compiler.compile import compile_blueprint
+from octop.modules.org_os.lifecycle.store import LifecycleStore
+from octop.modules.org_os.lifecycle.transitions import register_compiled
+from octop.modules.org_os.loop.fixtures import blueprint_fixture, extra_blueprint_fixtures
+from octop.modules.org_os.runtime.spawn import list_spawned_agents, spawn_colleague_agent
+from octop.modules.org_os.service import OrgModuleService
+
+
+def assemble_from_blueprint(service: OrgModuleService) -> dict[str, Any]:
+ """Import control-plane catalog/policies (when live) and compile real blueprints."""
+ tid = service.tenant_id() or "default"
+ sidecar = service.probe_sidecar()
+ notes: list[str] = []
+ employees: list[str] = []
+ imported: dict[str, Any] | None = None
+ if sidecar.reachable:
+ inbound = import_openxyos_assets(
+ service.home,
+ tenant_id=tid,
+ sidecar_url=service.sidecar_url(),
+ catalog=True,
+ from_sidecar=True,
+ )
+ imported = inbound.to_dict()
+ employees.extend(inbound.employees)
+ notes.extend(inbound.notes)
+ notes.append("imported openXYOS catalog/policies from the live sidecar")
+ else:
+ notes.append("sidecar offline; compiling bundled openXYOS blueprint fixtures")
+
+ store = LifecycleStore(service.home, tid)
+ paths = [blueprint_fixture(), *extra_blueprint_fixtures()]
+ for path in paths:
+ compiled = compile_blueprint(
+ path,
+ home=service.home,
+ tenant_id=tid,
+ sidecar_url=service.sidecar_url(),
+ )
+ register_compiled(
+ store,
+ slug=compiled.slug,
+ name=compiled.slug,
+ workspace=compiled.workspace,
+ lifecycle="draft",
+ )
+ employees.append(compiled.slug)
+ notes.append(f"compiled blueprint {path.name} → {compiled.slug}")
+
+ already = {row.slug for row in list_spawned_agents(service.home)}
+ spawned: list[dict[str, Any]] = []
+ for record in store.list():
+ if record.slug in already:
+ continue
+ spawned.append(spawn_colleague_agent(service.home, record).to_dict())
+ return {
+ "sidecar_reachable": sidecar.reachable,
+ "employees": list(dict.fromkeys(employees)),
+ "spawned": spawned,
+ "imported": imported,
+ "notes": notes,
+ }
+
+
+def pack_to_openxyos(service: OrgModuleService) -> dict[str, Any]:
+ """Publish a FreeOS asset pack and apply it to the local / live control plane."""
+ pack = publish_asset_pack(service.home, tenant_id=service.tenant_id())
+ applied = apply_asset_pack(
+ pack.directory,
+ home=service.home,
+ tenant_id=service.tenant_id() or "default",
+ base_url=service.sidecar_url(),
+ )
+ return {"pack": pack.to_dict(), "applied": applied.to_dict()}
diff --git a/src/octop/modules/org_os/overview.py b/src/octop/modules/org_os/overview.py
new file mode 100644
index 00000000..d7ae29a7
--- /dev/null
+++ b/src/octop/modules/org_os/overview.py
@@ -0,0 +1,137 @@
+"""Dual-loop snapshot: FreeOS data plane ↔ openXYOS control plane."""
+
+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.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
+from octop.modules.org_os.service import OrgModuleService
+
+
+def _mtime_iso(path: Path) -> str | None:
+ if not path.is_file():
+ return None
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(path.stat().st_mtime))
+
+
+def _count_skill_dirs(home: Path) -> int:
+ root = home / "org-skills"
+ if not root.is_dir():
+ return 0
+ return sum(1 for child in root.iterdir() if child.is_dir() and (child / "SKILL.md").is_file())
+
+
+def _read_loop_proof(home: Path) -> dict[str, Any] | None:
+ path = home / "asset-packs" / "loop-proof.json"
+ if not path.is_file():
+ return None
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return None
+ if not isinstance(raw, dict):
+ return None
+ raw["updated_at"] = _mtime_iso(path)
+ return raw
+
+
+@dataclass
+class DualLoopOverview:
+ enabled: bool
+ sidecar_reachable: bool
+ sidecar_url: str
+ start_available: bool
+ start_command: str
+ home: str
+ last_sync: str | None
+ freeos: dict[str, Any]
+ openxyos: dict[str, Any]
+ last_loop: dict[str, Any] | None = None
+ notes: list[str] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "enabled": self.enabled,
+ "sidecar_reachable": self.sidecar_reachable,
+ "sidecar_url": self.sidecar_url,
+ "start_available": self.start_available,
+ "start_command": self.start_command,
+ "home": self.home,
+ "last_sync": self.last_sync,
+ "freeos": dict(self.freeos),
+ "openxyos": dict(self.openxyos),
+ "last_loop": self.last_loop,
+ "notes": list(self.notes),
+ "catalog": list(OPENXYOS_MODULES),
+ }
+
+
+def build_overview(
+ service: OrgModuleService,
+ *,
+ agents: int = 0,
+ connectors: int = 0,
+ cron_jobs: int = 0,
+ skill_packages: int = 0,
+ start_available: bool = False,
+) -> DualLoopOverview:
+ status = service.status()
+ tid = service.tenant_id() or "default"
+ colleagues = LifecycleStore(service.home, tid).list()
+ spawned = list_spawned_agents(service.home)
+ org_skills = _count_skill_dirs(service.home)
+ proof = _read_loop_proof(service.home)
+ last_sync = None
+ if proof and isinstance(proof.get("updated_at"), str):
+ last_sync = str(proof["updated_at"])
+ pack_manifest = service.home / "asset-packs" / "latest" / "manifest.json"
+ if last_sync is None:
+ last_sync = _mtime_iso(pack_manifest)
+
+ freeos = {
+ "employees": len(colleagues),
+ "employee_states": {
+ row.lifecycle: sum(1 for item in colleagues if item.lifecycle == row.lifecycle)
+ for row in colleagues
+ },
+ "agents": max(agents, len(spawned)),
+ "spawned_colleagues": len(spawned),
+ "org_skills": org_skills,
+ "skill_packages": skill_packages,
+ "mcp": connectors,
+ "tasks": cron_jobs,
+ }
+ openxyos = {
+ "reachable": status.sidecar.reachable,
+ "url": status.sidecar.url,
+ "detail": status.sidecar.detail,
+ "modules": len(catalog_keys()),
+ "governance": status.governance_enabled,
+ "tenant_id": status.tenant_id or tid,
+ "approvals": 0,
+ }
+ notes = [
+ "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。",
+ "Data plane: employees / skills / MCP / tasks. Control plane: blueprints / modules / governance.",
+ ]
+ if not status.sidecar.reachable:
+ notes.append("openXYOS sidecar is offline — start it to sync blueprints and approvals.")
+ return DualLoopOverview(
+ enabled=status.enabled,
+ sidecar_reachable=status.sidecar.reachable,
+ sidecar_url=status.sidecar.url,
+ start_available=start_available,
+ start_command=status.start_command,
+ home=status.home,
+ last_sync=last_sync,
+ freeos=freeos,
+ openxyos=openxyos,
+ last_loop=proof,
+ notes=notes,
+ )
diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py
new file mode 100644
index 00000000..fd09161e
--- /dev/null
+++ b/src/octop/modules/org_os/sidecar_launch.py
@@ -0,0 +1,118 @@
+"""Start the bundled openXYOS sidecar when the desktop portable ships it."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from octop.infra.utils.paths import PathLayout
+from octop.modules.org_os.service import OrgModuleService
+
+
+@dataclass(frozen=True)
+class SidecarStartResult:
+ started: bool
+ already: bool
+ reachable: bool
+ url: str
+ command: str
+ detail: str
+ launcher: str = ""
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "started": self.started,
+ "already": self.already,
+ "reachable": self.reachable,
+ "url": self.url,
+ "command": self.command,
+ "detail": self.detail,
+ "launcher": self.launcher,
+ }
+
+
+def portable_root() -> Path | None:
+ green = (os.environ.get("OCTOP_GREEN_PACKAGES") or "").strip()
+ if green:
+ return Path(green).expanduser().resolve().parent
+ home = PathLayout.from_env().root
+ candidate = home / "portable"
+ if (candidate / "org-sidecar").is_dir():
+ return candidate
+ return None
+
+
+def find_sidecar_launcher() -> Path | None:
+ root = portable_root()
+ if root is not None:
+ bundled = root / "org-sidecar"
+ for name in ("start-sidecar.bat", "start-sidecar.sh"):
+ path = bundled / name
+ if path.is_file():
+ if name.endswith(".bat") and sys.platform != "win32":
+ continue
+ if name.endswith(".sh") and sys.platform == "win32":
+ continue
+ return path
+ here = Path(__file__).resolve()
+ for parent in here.parents:
+ script = parent / "scripts" / "run-org-sidecar.sh"
+ if script.is_file() and sys.platform != "win32":
+ return script
+ return None
+
+
+def start_sidecar(service: OrgModuleService, *, wait: float = 12.0) -> SidecarStartResult:
+ health = service.probe_sidecar()
+ launcher = find_sidecar_launcher()
+ command = str(launcher) if launcher is not None else service.status().start_command
+ if health.reachable:
+ return SidecarStartResult(
+ started=False,
+ already=True,
+ reachable=True,
+ url=health.url,
+ command=command,
+ detail="sidecar already reachable",
+ launcher=str(launcher or ""),
+ )
+ if launcher is None or not launcher.is_file():
+ return SidecarStartResult(
+ started=False,
+ already=False,
+ reachable=False,
+ url=health.url,
+ command=command,
+ detail="no bundled sidecar launcher; run scripts/run-org-sidecar.sh from a source tree",
+ )
+ popen_kwargs: dict[str, Any] = {
+ "cwd": str(launcher.parent),
+ "stdout": subprocess.DEVNULL,
+ "stderr": subprocess.DEVNULL,
+ "start_new_session": True,
+ }
+ if sys.platform == "win32":
+ cmd: list[str] = [str(launcher)]
+ else:
+ cmd = ["bash", str(launcher)]
+ # NOCA:DangerousSubprocessUseAudit(argv list; launcher is a bundled script we own)
+ subprocess.Popen(cmd, **popen_kwargs)
+ deadline = time.time() + max(wait, 1.0)
+ last = service.probe_sidecar()
+ while time.time() < deadline and not last.reachable:
+ time.sleep(0.4)
+ last = service.probe_sidecar()
+ return SidecarStartResult(
+ started=True,
+ already=False,
+ reachable=last.reachable,
+ url=last.url,
+ command=command,
+ detail="ok" if last.reachable else "started but not reachable yet",
+ launcher=str(launcher),
+ )
diff --git a/tests/unit/api/test_update_router.py b/tests/unit/api/test_update_router.py
index 3a4f25c4..08cbbbc9 100644
--- a/tests/unit/api/test_update_router.py
+++ b/tests/unit/api/test_update_router.py
@@ -279,64 +279,86 @@ def set(self, key: str, value: str) -> None:
def test_build_status_success_reports_source(monkeypatch: pytest.MonkeyPatch) -> None:
- info = self_update.PyPIInfo(version="1.2.3", description="desc", source="mirrors.aliyun.com")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.0.2", description="desc", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
payload = update_router._build_status()
- assert payload["latest_version"] == "1.2.3"
+ assert payload["latest_version"] == "0.0.2"
assert payload["error"] is None
assert payload["error_code"] is None
- assert payload["source"] == "mirrors.aliyun.com"
+ assert payload["source"] == "github.com/XYAIStudio/FreeOS"
def test_build_status_failure_via_check_keeps_error_code(
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = update_router._build_status(
- latest=None, error="could not reach PyPI", error_code="pypi_unreachable"
+ latest=None,
+ error="could not reach GitHub Releases for XYAIStudio/FreeOS",
+ error_code="github_unreachable",
)
assert payload["latest_version"] is None
- assert payload["error"] == "could not reach PyPI"
- assert payload["error_code"] == "pypi_unreachable"
+ assert payload["error"] == "could not reach GitHub Releases for XYAIStudio/FreeOS"
+ assert payload["error_code"] == "github_unreachable"
+
+
+def test_same_freeos_version_is_not_an_update(monkeypatch: pytest.MonkeyPatch) -> None:
+ info = self_update.GitHubReleaseInfo(
+ version="0.0.1", latest_stable="0.0.1", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
+ monkeypatch.setattr(update_router, "get_local_version", lambda: "0.0.1")
+
+ payload = update_router._build_status()
+ assert payload["latest_version"] == "0.0.1"
+ assert payload["has_update"] is False
@pytest.mark.asyncio
-async def test_check_endpoint_reports_error_code_when_pypi_unreachable(
+async def test_check_endpoint_reports_error_code_when_github_unreachable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: None)
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: None)
result = await update_router.check_for_updates(_=None, server=_settings_server())
assert result["latest_version"] is None
- assert result["error"] == "could not reach PyPI"
- assert result["error_code"] == "pypi_unreachable"
+ assert "GitHub Releases" in (result["error"] or "")
+ assert result["error_code"] == "github_unreachable"
@pytest.mark.asyncio
async def test_check_endpoint_success_passes_source(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="1.2.3", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.0.2", latest_stable="0.0.2", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
+ monkeypatch.setattr(update_router, "get_local_version", lambda: "0.0.1")
result = await update_router.check_for_updates(_=None, server=_settings_server())
- assert result["latest_version"] == "1.2.3"
- assert result["source"] == "pypi.org"
+ assert result["latest_version"] == "0.0.2"
+ assert result["source"] == "github.com/XYAIStudio/FreeOS"
assert result["error"] is None
assert result["stable_only"] is True
assert result["latest_is_prerelease"] is False
+ assert result["has_update"] is True
@pytest.mark.asyncio
async def test_status_stable_only_ignores_prerelease(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.9.33", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.9.34b1", latest_stable="0.9.33", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
monkeypatch.setattr(update_router, "get_local_version", lambda: "0.9.32")
auto = await update_router.update_status(_=None, server=_settings_server(True))
@@ -355,8 +377,10 @@ async def test_status_stable_only_ignores_prerelease(
async def test_status_includes_prerelease_when_stable_only_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.9.33", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.9.34b1", latest_stable="0.9.33", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
monkeypatch.setattr(update_router, "get_local_version", lambda: "0.9.33")
auto = await update_router.update_status(_=None, server=_settings_server(False))
@@ -369,8 +393,10 @@ async def test_status_includes_prerelease_when_stable_only_off(
async def test_patch_settings_remaps_cached_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.9.33", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.9.34b1", latest_stable="0.9.33", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
monkeypatch.setattr(update_router, "get_local_version", lambda: "0.9.33")
server = _settings_server(True)
@@ -390,8 +416,10 @@ async def test_patch_settings_remaps_cached_status(
async def test_trigger_upgrade_pins_stable_when_prerelease_exists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.9.33", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.9.34b1", latest_stable="0.9.33", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
monkeypatch.setattr(update_router, "get_editable_path", lambda: None)
captured: dict[str, object] = {}
@@ -424,8 +452,10 @@ async def fake_worker(
async def test_trigger_upgrade_defaults_to_channel_latest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- info = self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.9.33", source="pypi.org")
- monkeypatch.setattr(update_router, "fetch_pypi_info", lambda: info)
+ info = self_update.GitHubReleaseInfo(
+ version="0.9.34b1", latest_stable="0.9.33", source="github.com/XYAIStudio/FreeOS"
+ )
+ monkeypatch.setattr(update_router, "fetch_release_info", lambda: info)
monkeypatch.setattr(update_router, "get_editable_path", lambda: None)
captured: dict[str, object] = {}
diff --git a/tests/unit/cli/test_update_cmd.py b/tests/unit/cli/test_update_cmd.py
index ccfa1f5d..9b5f0861 100644
--- a/tests/unit/cli/test_update_cmd.py
+++ b/tests/unit/cli/test_update_cmd.py
@@ -16,7 +16,7 @@ def test_update_check_only_no_install(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="9.9.9", latest_stable="9.9.9"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
@@ -42,14 +42,11 @@ def test_update_yes_runs_upgrade(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="9.9.9", latest_stable="9.9.9"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
monkeypatch.setattr(update_cmd, "get_editable_path", lambda: None)
- monkeypatch.setattr(
- update_cmd, "resolve_venv_python", lambda: "/home/user/.octop/venv/bin/python"
- )
called: dict[str, bool] = {}
@@ -66,7 +63,7 @@ def _fake_upgrade(**kw):
assert called.get("ran") is True
assert called["kwargs"]["version"] == "9.9.9"
assert called["kwargs"]["allow_prerelease"] is False
- assert "/home/user/.octop/venv/bin/python" in result.output
+ assert "github.com/XYAIStudio/FreeOS" in result.output
def test_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -74,7 +71,7 @@ def test_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="0.1.0", latest_stable="0.1.0"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
@@ -86,37 +83,9 @@ def test_update_already_latest(monkeypatch: pytest.MonkeyPatch) -> None:
assert "up to date" in result.output.lower()
-def test_build_upgrade_command_uses_managed_venv_python() -> None:
- python = "/home/user/.octop/venv/bin/python"
- cmd = self_update.build_upgrade_command("uv", python, index_url="https://mirror.example/simple")
- assert cmd is not None
- assert "--python" in cmd
- assert python in cmd
- assert "--upgrade-package" in cmd
- assert "octop" in cmd
- assert "https://mirror.example/simple" in cmd
- assert "--target" not in cmd
-
-
-def test_build_upgrade_command_pins_version() -> None:
- python = "/home/user/.octop/venv/bin/python"
- cmd = self_update.build_upgrade_command("uv", python, version="0.9.33")
- assert cmd is not None
- assert "octop==0.9.33" in cmd
- assert "--prerelease" not in cmd
-
-
-def test_build_upgrade_command_green_packages_target(
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
-) -> None:
- packages = tmp_path / "packages"
- packages.mkdir()
- monkeypatch.setenv("OCTOP_GREEN_PACKAGES", str(packages))
- python = "/opt/octop/runtime/bin/python3"
- cmd = self_update.build_upgrade_command("uv", python)
- assert cmd is not None
- assert "--target" in cmd
- assert str(packages) in cmd
+def test_package_requirement_refuses_pypi_octop() -> None:
+ with pytest.raises(RuntimeError, match="does not install the upstream octop"):
+ self_update.package_requirement("1.0.0")
def test_resolve_venv_python_green_uses_current_interpreter(
@@ -155,7 +124,7 @@ def test_update_check_skips_prerelease_by_default(monkeypatch: pytest.MonkeyPatc
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.1.0"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
@@ -176,7 +145,7 @@ def test_update_allow_prerelease_check_shows_prerelease(
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.1.0"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
@@ -195,14 +164,11 @@ def test_update_allow_prerelease_installs_prerelease(
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="0.9.34b1", latest_stable="0.1.0"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
monkeypatch.setattr(update_cmd, "get_editable_path", lambda: None)
- monkeypatch.setattr(
- update_cmd, "resolve_venv_python", lambda: "/home/user/.octop/venv/bin/python"
- )
called: dict[str, object] = {}
@@ -226,7 +192,7 @@ def test_update_editable_install_exits_nonzero(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(
update_cmd,
- "fetch_pypi_info",
+ "fetch_release_info",
lambda: self_update.PyPIInfo(version="9.9.9", latest_stable="9.9.9"),
)
monkeypatch.setattr(update_cmd, "get_local_version", lambda: "0.1.0")
diff --git a/tests/unit/infra/setup/test_github_releases.py b/tests/unit/infra/setup/test_github_releases.py
new file mode 100644
index 00000000..87a70962
--- /dev/null
+++ b/tests/unit/infra/setup/test_github_releases.py
@@ -0,0 +1,125 @@
+"""GitHub Releases update channel — FreeOS only, never Octop/PyPI."""
+
+from __future__ import annotations
+
+import pytest
+
+from octop.infra.setup.github_releases import (
+ GITHUB_RELEASES_API,
+ SOURCE_LABEL,
+ GitHubReleaseInfo,
+ assert_allowed_update_url,
+ parse_github_releases,
+ pick_portable_asset,
+ pick_release_versions,
+)
+from octop.infra.setup.self_update import is_prerelease, parse_version
+
+
+def test_assert_allowed_update_url_accepts_github() -> None:
+ assert assert_allowed_update_url(GITHUB_RELEASES_API).startswith("https://api.github.com/")
+ assert SOURCE_LABEL == "github.com/XYAIStudio/FreeOS"
+
+
+@pytest.mark.parametrize(
+ "url",
+ [
+ "https://pypi.org/pypi/octop/json",
+ "https://mirrors.cloud.tencent.com/pypi/simple/octop/",
+ "https://mirrors.aliyun.com/pypi/simple/octop/",
+ "https://pypi.tuna.tsinghua.edu.cn/simple/octop/",
+ "https://mirrors.ustc.edu.cn/pypi/simple/octop/",
+ "https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh",
+ "https://octop-1258344699.cos.ap-guangzhou.myqcloud.com/octop/FreeOS.zip",
+ ],
+)
+def test_assert_allowed_update_url_rejects_octop_feeds(url: str) -> None:
+ with pytest.raises(ValueError, match="refusing"):
+ assert_allowed_update_url(url)
+
+
+def test_parse_github_releases_ignores_octop_assets_and_drafts() -> None:
+ rows = parse_github_releases(
+ [
+ {
+ "tag_name": "v1.0.0",
+ "draft": False,
+ "body": "upstream Octop",
+ "html_url": "https://github.com/other/octop/releases/tag/v1.0.0",
+ "assets": [
+ {
+ "name": "octop-desktop-windows-amd64-1.0.0.exe",
+ "browser_download_url": "https://github.com/other/octop/releases/download/v1.0.0/octop.exe",
+ }
+ ],
+ },
+ {
+ "tag_name": "v0.0.1",
+ "draft": False,
+ "body": "## [0.0.1]\nFreeOS",
+ "html_url": "https://github.com/XYAIStudio/FreeOS/releases/tag/v0.0.1",
+ "assets": [
+ {
+ "name": "FreeOS-portable-windows-amd64-0.0.1.zip",
+ "browser_download_url": (
+ "https://github.com/XYAIStudio/FreeOS/releases/download/"
+ "v0.0.1/FreeOS-portable-windows-amd64-0.0.1.zip"
+ ),
+ }
+ ],
+ },
+ ]
+ )
+ assert [ver for ver, _ in rows] == ["0.0.1"]
+ assert rows[0][1].assets[0].name.startswith("FreeOS-portable-")
+
+
+def test_pick_release_versions_skips_prerelease_for_stable() -> None:
+ rows = [
+ (
+ "0.0.2a1",
+ GitHubReleaseInfo(version="0.0.2a1", latest_stable=None, assets=[]),
+ ),
+ (
+ "0.0.1",
+ GitHubReleaseInfo(version="0.0.1", latest_stable=None, assets=[]),
+ ),
+ ]
+ latest_any, latest_stable = pick_release_versions(
+ rows, is_prerelease=is_prerelease, parse_version=parse_version
+ )
+ assert latest_any is not None
+ assert latest_any.version == "0.0.2a1"
+ assert latest_stable is not None
+ assert latest_stable.version == "0.0.1"
+
+
+def test_pick_portable_asset_requires_matching_plat() -> None:
+ info_rows = parse_github_releases(
+ [
+ {
+ "tag_name": "v0.0.2",
+ "assets": [
+ {
+ "name": "FreeOS-portable-darwin-arm64-0.0.2.zip",
+ "browser_download_url": (
+ "https://github.com/XYAIStudio/FreeOS/releases/download/"
+ "v0.0.2/FreeOS-portable-darwin-arm64-0.0.2.zip"
+ ),
+ },
+ {
+ "name": "FreeOS-portable-windows-amd64-0.0.2.zip",
+ "browser_download_url": (
+ "https://github.com/XYAIStudio/FreeOS/releases/download/"
+ "v0.0.2/FreeOS-portable-windows-amd64-0.0.2.zip"
+ ),
+ },
+ ],
+ }
+ ]
+ )
+ assets = info_rows[0][1].assets
+ picked = pick_portable_asset(assets, plat="windows-amd64", version="0.0.2")
+ assert picked is not None
+ assert "windows-amd64" in picked.name
+ assert pick_portable_asset(assets, plat="linux-arm64", version="0.0.2") is None
diff --git a/tests/unit/infra/setup/test_self_update.py b/tests/unit/infra/setup/test_self_update.py
index 45129972..79b0cf58 100644
--- a/tests/unit/infra/setup/test_self_update.py
+++ b/tests/unit/infra/setup/test_self_update.py
@@ -2,14 +2,19 @@
from __future__ import annotations
+from pathlib import Path
+
import pytest
+from octop.infra.setup.github_releases import GitHubReleaseInfo, ReleaseAsset
from octop.infra.setup.self_update import (
- build_upgrade_command,
is_newer,
is_prerelease,
+ package_requirement,
parse_version,
+ pending_portable_zip,
pick_latest_versions,
+ run_upgrade,
)
@@ -52,31 +57,60 @@ def test_pick_latest_versions_all_prerelease() -> None:
assert latest_stable is None
-def test_build_upgrade_command_prerelease_flags(
- monkeypatch: pytest.MonkeyPatch,
+def test_package_requirement_never_installs_upstream_octop() -> None:
+ with pytest.raises(RuntimeError, match="XYAIStudio/FreeOS"):
+ package_requirement("1.0.0")
+
+
+def test_same_version_is_not_newer() -> None:
+ assert not is_newer("0.0.1", "0.0.1")
+ assert is_newer("0.0.2", "0.0.1")
+ assert not is_newer("0.0.1", "1.0.0")
+
+
+def test_run_upgrade_stages_freeos_portable(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
- python = "/home/user/.octop/venv/bin/python"
- uv_cmd = build_upgrade_command("uv", python, allow_prerelease=True, version="0.9.34b1")
- assert uv_cmd is not None
- assert uv_cmd[uv_cmd.index("--prerelease") + 1] == "allow"
- assert "octop==0.9.34b1" in uv_cmd
- monkeypatch.setattr("octop.infra.setup.self_update.has_pip", lambda _: True)
- pip_cmd = build_upgrade_command("pip", python, allow_prerelease=True, version="0.9.34b1")
- assert pip_cmd is not None
- assert "--pre" in pip_cmd
- assert "octop==0.9.34b1" in pip_cmd
-
-
-def test_build_upgrade_command_pins_stable_without_pre(
- monkeypatch: pytest.MonkeyPatch,
+ zip_url = (
+ "https://github.com/XYAIStudio/FreeOS/releases/download/"
+ "v0.0.2/FreeOS-portable-linux-amd64-0.0.2.zip"
+ )
+ info = GitHubReleaseInfo(
+ version="0.0.2",
+ latest_stable="0.0.2",
+ assets=[ReleaseAsset(name="FreeOS-portable-linux-amd64-0.0.2.zip", url=zip_url)],
+ )
+ monkeypatch.setattr("octop.infra.setup.self_update.fetch_release_info", lambda: info)
+ monkeypatch.setattr("octop.infra.setup.self_update.desktop_plat", lambda: "linux-amd64")
+ monkeypatch.setenv("FREEOS_HOME", str(tmp_path))
+ monkeypatch.setenv("OCTOP_HOME", str(tmp_path))
+ monkeypatch.setenv("OCTOP_DESKTOP", "1")
+
+ def fake_download(url: str, dest: Path, **_: object) -> None:
+ dest.write_bytes(b"PK\x03\x04")
+
+ monkeypatch.setattr("octop.infra.setup.self_update.download_release_asset", fake_download)
+
+ result = run_upgrade(version="0.0.2")
+ assert result.success is True
+ assert result.installed_version == "0.0.2"
+ assert pending_portable_zip(tmp_path).is_file()
+ assert "staged FreeOS 0.0.2" in (result.message or "")
+
+
+def test_run_upgrade_does_not_pip_install_octop(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
- python = "/home/user/.octop/venv/bin/python"
- uv_cmd = build_upgrade_command("uv", python, version="0.9.33")
- assert uv_cmd is not None
- assert "octop==0.9.33" in uv_cmd
- assert "--prerelease" not in uv_cmd
- monkeypatch.setattr("octop.infra.setup.self_update.has_pip", lambda _: True)
- pip_cmd = build_upgrade_command("pip", python, version="0.9.33")
- assert pip_cmd is not None
- assert "octop==0.9.33" in pip_cmd
- assert "--pre" not in pip_cmd
+ info = GitHubReleaseInfo(version="0.0.1", latest_stable="0.0.1", assets=[])
+ monkeypatch.setattr("octop.infra.setup.self_update.fetch_release_info", lambda: info)
+ monkeypatch.setattr("octop.infra.setup.self_update.desktop_plat", lambda: "linux-amd64")
+ monkeypatch.setenv("FREEOS_HOME", str(tmp_path))
+ monkeypatch.delenv("OCTOP_DESKTOP", raising=False)
+ monkeypatch.delenv("FREEOS_DESKTOP", raising=False)
+ monkeypatch.delenv("OCTOP_GREEN_PACKAGES", raising=False)
+
+ result = run_upgrade(version="0.0.1")
+ assert result.success is False
+ assert result.error is not None
+ assert "PyPI" in result.error or "GitHub" in result.error
+ assert "octop==" not in result.error
diff --git a/tests/unit/test_org_module.py b/tests/unit/test_org_module.py
index cf71a275..ffe22e6c 100644
--- a/tests/unit/test_org_module.py
+++ b/tests/unit/test_org_module.py
@@ -161,6 +161,43 @@ class _User:
assert headers["X-FreeOS-Tenant-Id"] == "99"
+def test_overview_reports_real_empty_counts(tmp_path: Path) -> None:
+ from octop.modules.org_os.overview import build_overview
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ snapshot = build_overview(service, agents=2, connectors=1, cron_jobs=3, skill_packages=0)
+ payload = snapshot.to_dict()
+ assert payload["freeos"]["employees"] == 0
+ assert payload["freeos"]["agents"] == 2
+ assert payload["freeos"]["mcp"] == 1
+ assert payload["freeos"]["tasks"] == 3
+ assert payload["sidecar_reachable"] is False
+ assert payload["last_loop"] is None
+ assert any("FreeOS 负责干活" in note for note in payload["notes"])
+
+
+def test_assemble_from_bundled_blueprint(tmp_path: Path) -> None:
+ from octop.modules.org_os.empower import assemble_from_blueprint
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ result = assemble_from_blueprint(service)
+ assert result["employees"]
+ assert result["spawned"]
+ assert result["sidecar_reachable"] is False
+ assert any("blueprint" in note for note in result["notes"])
+
+
+def test_pack_to_openxyos_writes_local_mirror(tmp_path: Path) -> None:
+ from octop.modules.org_os.empower import assemble_from_blueprint, pack_to_openxyos
+
+ service = OrgModuleService(config_path=tmp_path / "config.json", home=tmp_path)
+ assemble_from_blueprint(service)
+ packed = pack_to_openxyos(service)
+ assert Path(packed["pack"]["directory"]).is_dir()
+ assert packed["applied"]["mirrored"] is True
+ assert packed["applied"]["remote_applied"] is False
+
+
def test_governance_enable_persists(tmp_path: Path) -> None:
config = tmp_path / "config.json"
service = OrgModuleService(config_path=config, home=tmp_path)