From da6ac6d8e8b061291d5df359ef466b503e720765 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 2 Jul 2026 18:11:55 +0800 Subject: [PATCH 01/10] feat(skill): support safeskill install sources --- flocks/cli/commands/skill.py | 9 +- flocks/server/routes/skill.py | 4 +- flocks/skill/installer.py | 95 ++++++++++++---- flocks/tool/skill/flocks_skills.py | 5 +- tests/skill/test_installer.py | 103 ++++++++++++++++++ tests/tool/test_flocks_skills.py | 27 +++++ webui/src/api/skill.ts | 4 +- webui/src/locales/en-US/skill.json | 4 +- webui/src/locales/zh-CN/skill.json | 4 +- .../pages/Skill/SkillInstallDialog.test.tsx | 91 ++++++++++++++++ webui/src/pages/Skill/SkillInstallDialog.tsx | 4 + 11 files changed, 314 insertions(+), 36 deletions(-) create mode 100644 webui/src/pages/Skill/SkillInstallDialog.test.tsx diff --git a/flocks/cli/commands/skill.py b/flocks/cli/commands/skill.py index ef9b6c01b..80b8ff028 100644 --- a/flocks/cli/commands/skill.py +++ b/flocks/cli/commands/skill.py @@ -366,11 +366,12 @@ async def _search_safeskill(query: str) -> list: name = item.get("name") or item.get("slug") or source if not source or not name: continue + install_hint = source if str(source).startswith("safeskill://") else f"safeskill:{source}" results.append({ "name": str(name), "description": str(item.get("description") or ""), "source": "safeskill.cn", - "install_hint": f"safeskill:{source}", + "install_hint": install_hint, }) return results except Exception: @@ -389,11 +390,12 @@ def _parse_safeskill_text_results(text: str) -> list: continue source = match.group(1).rstrip(",.;") name = source.rstrip("/").split("/")[-1] + install_hint = source if source.startswith("safeskill://") else f"safeskill:{source}" results.append({ "name": name, "description": clean, "source": "safeskill.cn", - "install_hint": f"safeskill:{source}", + "install_hint": install_hint, }) return results @@ -472,7 +474,8 @@ def install_skill( "Install source:\n" " clawhub: – clawhub.com registry\n" " skills-sh: – skills.sh identifier (owner/repo/skill)\n" - " safeskill: – SafeSkill Hub/GitHub/local source via SafeSkill CLI\n" + " safeskill://... – SafeSkill package URI\n" + " safeskill: – SafeSkill source alias via SafeSkill CLI\n" " github:/ – GitHub repository\n" " / – GitHub shorthand\n" " https://... – direct SKILL.md URL\n" diff --git a/flocks/server/routes/skill.py b/flocks/server/routes/skill.py index f270b15c8..28d0e3d7f 100644 --- a/flocks/server/routes/skill.py +++ b/flocks/server/routes/skill.py @@ -107,6 +107,8 @@ class SkillInstallRequest(BaseModel): "Install source. Supported formats:\n" " clawhub: – clawhub.com registry\n" " github:/ – GitHub repo\n" + " safeskill://... – SafeSkill package URI\n" + " safeskill: – SafeSkill source alias\n" " https://... – direct URL to SKILL.md\n" " /local/path – local file or directory\n" " / – shorthand for GitHub" @@ -329,9 +331,9 @@ async def install_skill(req: SkillInstallRequest, _user=Depends(require_user)): Supported sources: - `clawhub:` — clawhub.com registry (OpenClaw ecosystem) - `github:/` or `/` — GitHub repository + - `safeskill://...` or `safeskill:` — SafeSkill package URI/source - `https://...` — direct URL to a SKILL.md file - `/local/path` — local filesystem path - - `safeskill:` — SafeSkill registry (reserved, future) """ try: result = await SkillInstaller.install_from_source(req.source, scope=req.scope) diff --git a/flocks/skill/installer.py b/flocks/skill/installer.py index 98583903f..745427c1b 100644 --- a/flocks/skill/installer.py +++ b/flocks/skill/installer.py @@ -24,6 +24,7 @@ import platform import re import shutil +import signal import sys import tempfile import zipfile @@ -115,12 +116,12 @@ def _resolve_source(source: str) -> dict: prefix = "skills-sh:" if source.startswith("skills-sh:") else "skills.sh:" return {"kind": "skills_sh", "value": source[len(prefix):]} - if source.startswith("safeskill:"): - return {"kind": "safeskill", "value": source[len("safeskill:"):]} - if source.startswith("safeskill://"): return {"kind": "safeskill", "value": source} + if source.startswith("safeskill:"): + return {"kind": "safeskill", "value": source[len("safeskill:"):]} + if source.startswith("clawhub:"): return {"kind": "clawhub", "value": source[len("clawhub:"):]} @@ -167,6 +168,42 @@ def _resolve_source(source: str) -> dict: class SkillInstaller: """Install skills from external sources and manage skill dependencies.""" + @staticmethod + def _subprocess_stdio_kwargs( + *, + cwd: Optional[str] = None, + env: Optional[dict[str, str]] = None, + ) -> dict: + kwargs = { + "cwd": cwd, + "env": env, + "stdin": asyncio.subprocess.DEVNULL, + "stdout": asyncio.subprocess.PIPE, + "stderr": asyncio.subprocess.PIPE, + } + if os.name != "nt": + kwargs["start_new_session"] = True + return kwargs + + @staticmethod + def _signal_process_tree(proc, sig: signal.Signals) -> None: + pid = getattr(proc, "pid", None) + if os.name != "nt" and isinstance(pid, int) and pid > 0: + try: + os.killpg(pid, sig) + return + except ProcessLookupError: + return + except Exception: + pass + try: + if sig == signal.SIGKILL: + proc.kill() + else: + proc.terminate() + except ProcessLookupError: + pass + @staticmethod async def _run_subprocess( cmd: list[str], @@ -177,10 +214,7 @@ async def _run_subprocess( ) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *cmd, - cwd=cwd, - env=env, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + **SkillInstaller._subprocess_stdio_kwargs(cwd=cwd, env=env), ) try: stdout_b, stderr_b = await asyncio.wait_for( @@ -188,14 +222,15 @@ async def _run_subprocess( timeout=timeout_sec, ) except asyncio.TimeoutError: - try: - proc.kill() - except ProcessLookupError: - pass + SkillInstaller._signal_process_tree(proc, signal.SIGTERM) try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: - pass + SkillInstaller._signal_process_tree(proc, signal.SIGKILL) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + pass raise TimeoutError(f"Command timed out after {timeout_sec:g}s: {' '.join(cmd)}") return ( proc.returncode if proc.returncode is not None else 0, @@ -359,7 +394,7 @@ async def _install_from_skills_sh_cli( @classmethod async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallResult: - """Run SafeSkill CLI in a staging directory and import its agent output.""" + """Run SafeSkill CLI in an isolated home and import its Flocks output.""" npx = shutil.which("npx") if not npx: return SkillInstallResult( @@ -373,27 +408,32 @@ async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallR success=False, error=( "safeskill source is required, e.g. " - "safeskill:safeskill://official/acme/code-review" + "safeskill://official/acme/code-review" ), ) with tempfile.TemporaryDirectory(prefix="flocks-safeskill-") as tmp: staging = Path(tmp) + env = os.environ.copy() + env["HOME"] = str(staging) + env["XDG_CONFIG_HOME"] = str(staging / ".config") cmd = [ npx, "-y", "@safeskill/cli", + "--region", + "cn", "add", source, - "--copy", - "-y", - "-a", - "universal", + "--agent", + "flocks", + "--yes", ] try: returncode, stdout, stderr = await cls._run_subprocess( cmd, cwd=str(staging), + env=env, timeout_sec=_SKILLS_SH_CLI_TIMEOUT_SEC, ) except TimeoutError as exc: @@ -416,7 +456,7 @@ async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallR success=False, error=( "SafeSkill CLI completed but no SKILL.md files were found " - "in the staging agent directories." + "in the staged Flocks/agent skill directories." ), ) @@ -499,11 +539,12 @@ def _github_repo_slug(value: str) -> Optional[str]: @classmethod def _import_staged_skill_dirs(cls, staging: Path, scope: str) -> List[tuple[str, Path]]: - """Copy staged SafeSkill agent directories into Flocks skill storage.""" + """Copy staged agent skill directories into Flocks skill storage.""" install_root = _resolve_install_root(scope) imported: List[tuple[str, Path]] = [] seen: set[Path] = set() candidate_roots = [ + staging / ".flocks" / "plugins" / "skills", staging / ".agents" / "skills", staging / ".claude" / "skills", staging / ".cursor" / "skills", @@ -1208,16 +1249,22 @@ async def _execute_install_spec( try: proc = await asyncio.create_subprocess_exec( *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + **cls._subprocess_stdio_kwargs(), ) try: stdout_b, stderr_b = await asyncio.wait_for( proc.communicate(), timeout=timeout_sec ) except asyncio.TimeoutError: - proc.kill() - await proc.communicate() + cls._signal_process_tree(proc, signal.SIGTERM) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + cls._signal_process_tree(proc, signal.SIGKILL) + try: + await asyncio.wait_for(proc.communicate(), timeout=5) + except Exception: + pass return DepInstallResult( success=False, spec_id=spec.id, diff --git a/flocks/tool/skill/flocks_skills.py b/flocks/tool/skill/flocks_skills.py index 8b577cc60..ca7a68689 100644 --- a/flocks/tool/skill/flocks_skills.py +++ b/flocks/tool/skill/flocks_skills.py @@ -56,7 +56,8 @@ github:// e.g. github:octocat/skills/find-ioc clawhub: e.g. clawhub:ndr-alert-analysis skills-sh:// e.g. skills-sh:owner/repo/code-review - safeskill: e.g. safeskill:safeskill://official/acme/code-review + safeskill://... e.g. safeskill://official/acme/code-review@1.2.0 + safeskill: SafeSkill source alias https://... direct SKILL.md URL The tool auto-adds --yes so non-interactive agent calls do not hang on downstream CLI confirmation prompts (e.g. `skills add`). @@ -170,7 +171,7 @@ async def flocks_skills( if not source: return ToolResult( success=False, - error="install requires a source, e.g. github:owner/repo/skill-name", + error="install requires a source, e.g. github:owner/repo/skill-name or safeskill://...", ) if scope not in {"global", "project"}: return ToolResult( diff --git a/tests/skill/test_installer.py b/tests/skill/test_installer.py index 1420745e3..63bf9c98b 100644 --- a/tests/skill/test_installer.py +++ b/tests/skill/test_installer.py @@ -5,6 +5,7 @@ import asyncio import os import shutil +import signal import tempfile import io import zipfile @@ -76,6 +77,12 @@ def test_safeskill_scheme(self): assert r["kind"] == "safeskill" assert r["value"] == "ioc-lookup" + def test_safeskill_uri_scheme(self): + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + r = _resolve_source(source) + assert r["kind"] == "safeskill" + assert r["value"] == source + def test_clawhub_scheme(self): r = _resolve_source("clawhub:github") assert r["kind"] == "clawhub" @@ -281,6 +288,60 @@ def test_save_no_name_uses_hint(self, tmp_skills_dir: Path): # --------------------------------------------------------------------------- class TestInstallFromSource: + @pytest.mark.asyncio + async def test_run_subprocess_isolates_stdio_and_process_group(self): + proc = MagicMock() + proc.communicate = AsyncMock(return_value=(b"ok", b"")) + proc.returncode = 0 + + with patch( + "flocks.skill.installer.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ) as mock_exec: + returncode, stdout, stderr = await SkillInstaller._run_subprocess( + ["demo"], + timeout_sec=1, + ) + + assert returncode == 0 + assert stdout == "ok" + assert stderr == "" + kwargs = mock_exec.call_args.kwargs + assert kwargs["stdin"] == asyncio.subprocess.DEVNULL + assert kwargs["stdout"] == asyncio.subprocess.PIPE + assert kwargs["stderr"] == asyncio.subprocess.PIPE + if os.name != "nt": + assert kwargs["start_new_session"] is True + + @pytest.mark.asyncio + async def test_run_subprocess_timeout_terminates_process_group(self): + proc = MagicMock() + proc.pid = 12345 + proc.communicate = AsyncMock(return_value=(b"", b"")) + proc.returncode = None + wait_calls = 0 + + async def fake_wait_for(awaitable, timeout): + nonlocal wait_calls + wait_calls += 1 + if wait_calls == 1: + awaitable.close() + raise asyncio.TimeoutError() + return await awaitable + + with ( + patch( + "flocks.skill.installer.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch("flocks.skill.installer.asyncio.wait_for", fake_wait_for), + patch("flocks.skill.installer.os.killpg") as mock_killpg, + ): + with pytest.raises(TimeoutError): + await SkillInstaller._run_subprocess(["demo"], timeout_sec=1) + + mock_killpg.assert_called_once_with(12345, signal.SIGTERM) + @pytest.mark.asyncio async def test_skills_sh_cli_staging_imports_agent_skill(self, tmp_skills_dir): class Proc: @@ -352,6 +413,48 @@ async def test_safeskill_requires_npx(self, tmp_skills_dir): assert result.success is False assert "npx is required" in (result.error or "") + @pytest.mark.asyncio + async def test_safeskill_cli_uses_cn_region_and_flocks_agent(self, tmp_skills_dir): + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + captured = {} + + async def fake_run_subprocess(cmd, *, timeout_sec, cwd=None, env=None): + captured["cmd"] = cmd + captured["timeout_sec"] = timeout_sec + captured["cwd"] = cwd + captured["env"] = env + staged_skill = Path(env["HOME"]) / ".flocks" / "plugins" / "skills" / "onesig-use" + staged_skill.mkdir(parents=True) + (staged_skill / "SKILL.md").write_text( + "---\nname: onesig-use\ndescription: OneSig\n---\n", + encoding="utf-8", + ) + return 0, "✓ onesig-use (copied)", "" + + with ( + patch("flocks.skill.installer.shutil.which", return_value="/usr/bin/npx"), + patch("flocks.skill.installer._user_skills_root", return_value=tmp_skills_dir), + patch.object(SkillInstaller, "_run_subprocess", fake_run_subprocess), + ): + result = await SkillInstaller.install_from_source(source) + + assert result.success is True + assert result.skill_name == "onesig-use" + assert (tmp_skills_dir / "onesig-use" / "SKILL.md").exists() + assert captured["cmd"] == [ + "/usr/bin/npx", + "-y", + "@safeskill/cli", + "--region", + "cn", + "add", + source, + "--agent", + "flocks", + "--yes", + ] + assert captured["env"]["HOME"] == captured["cwd"] + @pytest.mark.asyncio async def test_local_file(self, tmp_path: Path, tmp_skills_dir: Path): skill_dir = tmp_path / "source-skill" diff --git a/tests/tool/test_flocks_skills.py b/tests/tool/test_flocks_skills.py index 8b5d585da..d9f49f472 100644 --- a/tests/tool/test_flocks_skills.py +++ b/tests/tool/test_flocks_skills.py @@ -186,6 +186,33 @@ async def test_nonzero_exit_returns_failure(): ctx.ask.assert_called_once() +@pytest.mark.asyncio +async def test_install_forwards_raw_safeskill_uri_args(): + from flocks.tool.skill.flocks_skills import flocks_skills + from flocks.skill.installer import SkillInstallResult + + source = "safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0" + ctx = make_ctx() + installer = AsyncMock( + return_value=SkillInstallResult( + success=True, + skill_name="onesig-use", + location="/tmp/onesig-use/SKILL.md", + message="installed", + ) + ) + + with patch("flocks.skill.installer.SkillInstaller.install_from_source", installer): + result = await flocks_skills(ctx, subcommand="install", args=source) + + assert result.success is True + installer.assert_awaited_once_with(source, scope="global", yes=True) + ctx.ask.assert_called_once() + assert ctx.ask.call_args.kwargs["patterns"] == [ + f"flocks skills install {source} --scope global --yes" + ] + + @pytest.mark.asyncio async def test_install_timeout_returns_failure(): from flocks.tool.skill.flocks_skills import flocks_skills diff --git a/webui/src/api/skill.ts b/webui/src/api/skill.ts index 8537aa8c6..254520cec 100644 --- a/webui/src/api/skill.ts +++ b/webui/src/api/skill.ts @@ -50,7 +50,7 @@ export interface Command { } export interface SkillInstallRequest { - /** Install source: clawhub:, github:/, https://..., /local/path */ + /** Install source: clawhub:, github:/, safeskill://..., https://..., /local/path */ source: string; /** 'global' (default) or 'project' */ scope?: string; @@ -115,9 +115,9 @@ export const skillAPI = { * Supported sources: * clawhub: – clawhub.com registry * github:/ – GitHub repo (or shorthand owner/repo) + * safeskill://... – SafeSkill package URI * https://... – direct URL to SKILL.md * /local/path – local filesystem - * safeskill: – SafeSkill registry (future) */ install: (req: SkillInstallRequest) => client.post('/api/skills/install', req), diff --git a/webui/src/locales/en-US/skill.json b/webui/src/locales/en-US/skill.json index fd4b11ff5..7d80338cd 100644 --- a/webui/src/locales/en-US/skill.json +++ b/webui/src/locales/en-US/skill.json @@ -66,8 +66,8 @@ "installDialog": { "title": "Install Skill", "sourceLabel": "Source", - "sourcePlaceholder": "Enter a source, e.g.:\nclawhub:github\ngithub:owner/repo\nhttps://raw.githubusercontent.com/...\n/local/path/to/skill", - "sourceHint": "Supports clawhub, GitHub, URL, local path, and future safeskill: sources", + "sourcePlaceholder": "Enter a source, e.g.:\nclawhub:github\ngithub:owner/repo\nsafeskill://tbx/.../onesig-use@1.0.0\nhttps://raw.githubusercontent.com/...\n/local/path/to/skill", + "sourceHint": "Supports clawhub, GitHub, SafeSkill, URL, and local path sources", "scopeLabel": "Install scope", "scopeGlobal": "Global (~/.flocks/plugins/skills/)", "scopeProject": "Project (.flocks/plugins/skills/)", diff --git a/webui/src/locales/zh-CN/skill.json b/webui/src/locales/zh-CN/skill.json index 681d6e9f6..45eabb2cb 100644 --- a/webui/src/locales/zh-CN/skill.json +++ b/webui/src/locales/zh-CN/skill.json @@ -65,8 +65,8 @@ "installDialog": { "title": "安装技能", "sourceLabel": "来源", - "sourcePlaceholder": "输入来源,例如:\nclawhub:github\ngithub:owner/repo\nhttps://raw.githubusercontent.com/...\n/local/path/to/skill", - "sourceHint": "支持 clawhub、GitHub、URL、本地路径,以及未来的 safeskill: 来源", + "sourcePlaceholder": "输入来源,例如:\nclawhub:github\ngithub:owner/repo\nsafeskill://tbx/.../onesig-use@1.0.0\nhttps://raw.githubusercontent.com/...\n/local/path/to/skill", + "sourceHint": "支持 clawhub、GitHub、SafeSkill、URL 和本地路径来源", "scopeLabel": "安装范围", "scopeGlobal": "全局(~/.flocks/plugins/skills/)", "scopeProject": "当前项目(.flocks/plugins/skills/)", diff --git a/webui/src/pages/Skill/SkillInstallDialog.test.tsx b/webui/src/pages/Skill/SkillInstallDialog.test.tsx new file mode 100644 index 000000000..49c9e16f8 --- /dev/null +++ b/webui/src/pages/Skill/SkillInstallDialog.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SkillInstallDialog from './SkillInstallDialog'; + +const SAFESKILL_EXAMPLE = 'safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0'; + +const { getMock, installMock, toastErrorMock, toastSuccessMock, toastWarningMock, tMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + installMock: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + toastWarningMock: vi.fn(), + tMock: vi.fn((key: string) => { + const labels: Record = { + 'installDialog.cancel': 'Cancel', + 'installDialog.install': 'Install', + 'installDialog.installing': 'Installing...', + 'installDialog.sourceHint': 'Supports SafeSkill', + 'installDialog.sourceLabel': 'Source', + 'installDialog.sourcePlaceholder': 'Enter a source', + 'installDialog.success': 'Installed', + 'installDialog.title': 'Install Skill', + installFailed: 'Install failed', + }; + return labels[key] ?? key; + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: tMock, + }), +})); + +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ + error: toastErrorMock, + success: toastSuccessMock, + warning: toastWarningMock, + }), +})); + +vi.mock('@/api/skill', () => ({ + skillAPI: { + get: getMock, + install: installMock, + }, +})); + +describe('SkillInstallDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + installMock.mockResolvedValue({ + data: { + success: true, + skill_name: 'onesig-use', + location: '/tmp/onesig-use/SKILL.md', + message: 'installed', + }, + }); + getMock.mockResolvedValue({ + data: { + name: 'onesig-use', + description: 'OneSig', + location: '/tmp/onesig-use/SKILL.md', + }, + }); + }); + + it('fills and installs a SafeSkill URI from the quick example', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onInstalled = vi.fn(); + + render(); + + await user.click(screen.getByRole('button', { name: 'SafeSkill' })); + + expect(screen.getByRole('textbox')).toHaveValue(SAFESKILL_EXAMPLE); + + await user.click(screen.getByRole('button', { name: 'Install' })); + + await waitFor(() => { + expect(installMock).toHaveBeenCalledWith({ source: SAFESKILL_EXAMPLE }); + }); + expect(onInstalled).toHaveBeenCalledWith(expect.objectContaining({ name: 'onesig-use' })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/webui/src/pages/Skill/SkillInstallDialog.tsx b/webui/src/pages/Skill/SkillInstallDialog.tsx index 3dce044ad..628dd8ef7 100644 --- a/webui/src/pages/Skill/SkillInstallDialog.tsx +++ b/webui/src/pages/Skill/SkillInstallDialog.tsx @@ -12,6 +12,10 @@ interface SkillInstallDialogProps { const SOURCE_EXAMPLES = [ { label: 'clawhub', value: 'clawhub:github' }, { label: 'GitHub', value: 'github:owner/repo' }, + { + label: 'SafeSkill', + value: 'safeskill://tbx/6ef3925b1f6245bcbd7da39f23c28652/onesig-use@1.0.0', + }, { label: 'URL', value: 'https://raw.githubusercontent.com/...' }, ]; From 481e7fac0875ea2203f354469aceb41f032f6b36 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Thu, 2 Jul 2026 17:58:23 +0800 Subject: [PATCH 02/10] fix channel message stale session binding fallback --- flocks/channel/inbound/session_binding.py | 27 +++++++ flocks/server/routes/channel.py | 23 +++++- flocks/tool/channel/channel_message.py | 22 +++++- tests/channel/test_session_binding.py | 27 +++++++ tests/server/routes/test_channel_routes.py | 86 ++++++++++++++++++++++ tests/tool/test_channel_message.py | 82 +++++++++++++++++++++ 6 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 tests/channel/test_session_binding.py create mode 100644 tests/server/routes/test_channel_routes.py diff --git a/flocks/channel/inbound/session_binding.py b/flocks/channel/inbound/session_binding.py index 465f21b0d..906f913c1 100644 --- a/flocks/channel/inbound/session_binding.py +++ b/flocks/channel/inbound/session_binding.py @@ -441,6 +441,33 @@ async def list_bindings( rows = await cursor.fetchall() return [self._row_to_binding(r) for r in rows] + async def latest_active_user_binding( + self, + *, + channel_id: str, + account_id: Optional[str] = None, + chat_id: Optional[str] = None, + ) -> Optional[SessionBinding]: + """Return the binding only when a channel target resolves uniquely.""" + from flocks.session.session import Session + + candidates = await self.list_bindings(channel_id=channel_id) + if account_id: + candidates = [b for b in candidates if b.account_id == account_id] + if chat_id: + candidates = [b for b in candidates if b.chat_id == chat_id] + + active_candidates: list[SessionBinding] = [] + for binding in candidates: + session = await Session.get_by_id(binding.session_id) + if ( + session + and session.status == "active" + and session.category == "user" + ): + active_candidates.append(binding) + return active_candidates[0] if len(active_candidates) == 1 else None + # --- internal helpers --- async def _find_binding( diff --git a/flocks/server/routes/channel.py b/flocks/server/routes/channel.py index aed3dc343..518e3fcbd 100644 --- a/flocks/server/routes/channel.py +++ b/flocks/server/routes/channel.py @@ -71,11 +71,26 @@ async def channel_session_send(req: SessionSendRequest): svc = SessionBindingService() all_bindings = await svc.list_bindings() matched = [b for b in all_bindings if b.session_id == req.session_id] + resolved_session_id = req.session_id + + if not matched and req.channel_type: + latest = await svc.latest_active_user_binding( + channel_id=req.channel_type, + account_id=req.account_id, + chat_id=req.chat_id, + ) + if latest: + matched = [latest] + resolved_session_id = latest.session_id if not matched: raise HTTPException( status_code=404, - detail=f"未找到 session '{req.session_id}' 的渠道绑定", + detail=( + f"未找到 session '{req.session_id}' 的渠道绑定;" + "请使用 im_send_message(resolve_only=true) 重新解析当前 IM 目标," + "或让用户确认目标 IM 会话。" + ), ) if req.channel_type: @@ -109,7 +124,10 @@ async def channel_session_send(req: SessionSendRequest): text=req.text, media_url=req.media_url, ) - results = await OutboundDelivery.deliver(out_ctx, session_id=req.session_id) + results = await OutboundDelivery.deliver( + out_ctx, + session_id=resolved_session_id, + ) all_results.extend(results) for r in results: if not r.success: @@ -120,6 +138,7 @@ async def channel_session_send(req: SessionSendRequest): return { "ok": True, + "session_id": resolved_session_id, "message_ids": [r.message_id for r in all_results if r.message_id], "channels": list({b.channel_id for b in matched}), } diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index bbe7ba19f..acc5e1c20 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -98,10 +98,11 @@ async def _http_session_send( ) body = resp.json() if resp.status_code == 200: + resolved_session_id = body.get("session_id") or session_id return ToolResult( success=True, output=( - f"Message sent to session '{session_id}' " + f"Message sent to session '{resolved_session_id}' " f"via channels {body.get('channels', [])}, " f"ids: {body.get('message_ids', [])}" ), @@ -217,13 +218,26 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: svc = SessionBindingService() all_bindings = await svc.list_bindings() matched = [b for b in all_bindings if b.session_id == session_id] + resolved_session_id = session_id + + if not matched and channel_type: + latest = await svc.latest_active_user_binding( + channel_id=channel_type, + account_id=account_id, + chat_id=chat_id, + ) + if latest: + matched = [latest] + resolved_session_id = latest.session_id if not matched: return ToolResult( success=False, error=( f"No channel binding found for session_id='{session_id}'. " - "Make sure the session was initiated via an IM channel." + "Resolve the current IM target again with " + "im_send_message(resolve_only=true), or ask the user to confirm " + "the target IM session." ), ) @@ -266,7 +280,7 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: text=message, media_url=media, ) - results = await OutboundDelivery.deliver(out_ctx, session_id=session_id) + results = await OutboundDelivery.deliver(out_ctx, session_id=resolved_session_id) all_results.extend(results) failed = [r for r in results if not r.success] @@ -284,7 +298,7 @@ async def channel_message(ctx: ToolContext, **kwargs) -> ToolResult: return ToolResult( success=True, output=( - f"Message sent to session '{session_id}' " + f"Message sent to session '{resolved_session_id}' " f"via channels {channels_sent}, " f"{len(all_results)} chunk(s), ids: {msg_ids}" ), diff --git a/tests/channel/test_session_binding.py b/tests/channel/test_session_binding.py new file mode 100644 index 000000000..45e7ad366 --- /dev/null +++ b/tests/channel/test_session_binding.py @@ -0,0 +1,27 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from flocks.channel.inbound.session_binding import SessionBindingService + + +@pytest.mark.asyncio +async def test_latest_active_user_binding_returns_none_when_channel_is_ambiguous() -> None: + first = SimpleNamespace(session_id="ses_newest") + second = SimpleNamespace(session_id="ses_other") + service = SessionBindingService() + service.list_bindings = AsyncMock(return_value=[first, second]) + + with patch( + "flocks.session.session.Session.get_by_id", + AsyncMock( + side_effect=[ + SimpleNamespace(status="active", category="user"), + SimpleNamespace(status="active", category="user"), + ] + ), + ): + result = await service.latest_active_user_binding(channel_id="wecom") + + assert result is None diff --git a/tests/server/routes/test_channel_routes.py b/tests/server/routes/test_channel_routes.py new file mode 100644 index 000000000..157383512 --- /dev/null +++ b/tests/server/routes/test_channel_routes.py @@ -0,0 +1,86 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from flocks.channel.base import DeliveryResult +from flocks.server.routes.channel import SessionSendRequest, channel_session_send +from fastapi import HTTPException + + +@pytest.mark.asyncio +async def test_channel_session_send_falls_back_to_latest_channel_binding() -> None: + latest_binding = SimpleNamespace( + session_id="ses_new", + channel_id="wecom", + account_id="default", + chat_id="room_1", + ) + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[latest_binding]), + latest_active_user_binding=AsyncMock(return_value=latest_binding), + ) + deliver_result = DeliveryResult( + channel_id="wecom", + message_id="msg_new", + chat_id="room_1", + ) + + with patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(return_value=[deliver_result]), + ) as deliver: + result = await channel_session_send( + SessionSendRequest( + session_id="ses_old", + text="hello", + channel_type="wecom", + ) + ) + + assert result["ok"] is True + assert result["session_id"] == "ses_new" + assert result["message_ids"] == ["msg_new"] + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_awaited_once() + assert deliver.await_args.kwargs["session_id"] == "ses_new" + + +@pytest.mark.asyncio +async def test_channel_session_send_returns_404_when_channel_binding_is_ambiguous() -> None: + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[]), + latest_active_user_binding=AsyncMock(return_value=None), + ) + + with patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(), + ) as deliver: + with pytest.raises(HTTPException) as exc_info: + await channel_session_send( + SessionSendRequest( + session_id="ses_old", + text="hello", + channel_type="wecom", + ) + ) + + assert exc_info.value.status_code == 404 + assert "im_send_message(resolve_only=true)" in str(exc_info.value.detail) + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_not_awaited() diff --git a/tests/tool/test_channel_message.py b/tests/tool/test_channel_message.py index a7dc1355c..add4aca2a 100644 --- a/tests/tool/test_channel_message.py +++ b/tests/tool/test_channel_message.py @@ -83,3 +83,85 @@ async def test_channel_message_exact_binding_filters_selected_chat_only() -> Non out_ctx = deliver.await_args.args[0] assert out_ctx.account_id == "acct_2" assert out_ctx.to == "chat_2" + + +@pytest.mark.asyncio +async def test_channel_message_falls_back_to_latest_channel_binding() -> None: + latest_binding = SimpleNamespace( + session_id="ses_new", + channel_id="wecom", + account_id="default", + chat_id="room_1", + ) + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[latest_binding]), + latest_active_user_binding=AsyncMock(return_value=latest_binding), + ) + deliver_result = DeliveryResult( + channel_id="wecom", + message_id="msg_new", + chat_id="room_1", + ) + + with patch( + "flocks.tool.channel.channel_message._http_session_send", + AsyncMock(return_value=None), + ), patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(return_value=[deliver_result]), + ) as deliver: + result = await channel_message( + ToolContext(session_id="ses_task", message_id="msg_1"), + session_id="ses_old", + message="hello", + channel_type="wecom", + ) + + assert result.success is True + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_awaited_once() + assert deliver.await_args.kwargs["session_id"] == "ses_new" + out_ctx = deliver.await_args.args[0] + assert out_ctx.account_id == "default" + assert out_ctx.to == "room_1" + + +@pytest.mark.asyncio +async def test_channel_message_does_not_fallback_when_channel_binding_is_ambiguous() -> None: + svc = SimpleNamespace( + list_bindings=AsyncMock(return_value=[]), + latest_active_user_binding=AsyncMock(return_value=None), + ) + + with patch( + "flocks.tool.channel.channel_message._http_session_send", + AsyncMock(return_value=None), + ), patch( + "flocks.channel.inbound.session_binding.SessionBindingService", + return_value=svc, + ), patch( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + AsyncMock(), + ) as deliver: + result = await channel_message( + ToolContext(session_id="ses_task", message_id="msg_1"), + session_id="ses_old", + message="hello", + channel_type="wecom", + ) + + assert result.success is False + assert "im_send_message(resolve_only=true)" in (result.error or "") + svc.latest_active_user_binding.assert_awaited_once_with( + channel_id="wecom", + account_id=None, + chat_id=None, + ) + deliver.assert_not_awaited() From 9266e58e7e7013147284bedfc50b5208d9fc5140 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Thu, 2 Jul 2026 18:23:33 +0800 Subject: [PATCH 03/10] Refresh device templates on page focus --- webui/src/pages/DeviceIntegration/index.test.tsx | 6 +++--- webui/src/pages/DeviceIntegration/index.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webui/src/pages/DeviceIntegration/index.test.tsx b/webui/src/pages/DeviceIntegration/index.test.tsx index 089cab010..35c00de2b 100644 --- a/webui/src/pages/DeviceIntegration/index.test.tsx +++ b/webui/src/pages/DeviceIntegration/index.test.tsx @@ -388,7 +388,7 @@ describe('DeviceIntegrationPage', () => { mocks.getSessionMessagesPage.mockResolvedValue({ items: [] }); }); - it('refreshes devices and templates without syncing when the window regains focus', async () => { + it('refreshes templates and syncs devices when the window regains focus', async () => { render(); await screen.findByText('设备接入'); @@ -403,10 +403,10 @@ describe('DeviceIntegrationPage', () => { await waitFor(() => { expect(mocks.listDevices).toHaveBeenCalledWith(); - expect(mocks.listTemplates).toHaveBeenCalledWith(); + expect(mocks.listTemplates).toHaveBeenCalledWith({ refresh: true }); expect(mocks.listGroups).toHaveBeenCalled(); }); - expect(mocks.syncDevices).not.toHaveBeenCalled(); + expect(mocks.syncDevices).toHaveBeenCalledWith({ refresh: true }); }); it('shows custom guidance and example entries on the add-device workbench', async () => { diff --git a/webui/src/pages/DeviceIntegration/index.tsx b/webui/src/pages/DeviceIntegration/index.tsx index fdf1b3c05..5f2da06c6 100644 --- a/webui/src/pages/DeviceIntegration/index.tsx +++ b/webui/src/pages/DeviceIntegration/index.tsx @@ -2024,7 +2024,7 @@ export default function DeviceIntegrationPage() { const now = Date.now(); if (now - lastRefreshRef.current < 1000) return; lastRefreshRef.current = now; - void fetchData(true); + void fetchData(true, true, true); }, [fetchData]); useEffect(() => { From 747fd57e2f3de61e0e9af336a4dbea2a95db2a3b Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 2 Jul 2026 18:31:38 +0800 Subject: [PATCH 04/10] fix(skill): isolate safeskill install home dirs --- flocks/skill/installer.py | 19 ++++++++++++++++--- tests/skill/test_installer.py | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/flocks/skill/installer.py b/flocks/skill/installer.py index 745427c1b..bc0f89c5a 100644 --- a/flocks/skill/installer.py +++ b/flocks/skill/installer.py @@ -204,6 +204,21 @@ def _signal_process_tree(proc, sig: signal.Signals) -> None: except ProcessLookupError: pass + @staticmethod + def _safeskill_staging_env(staging: Path) -> dict[str, str]: + env = os.environ.copy() + appdata = staging / "AppData" + env.update({ + "HOME": str(staging), + "USERPROFILE": str(staging), + "APPDATA": str(appdata / "Roaming"), + "LOCALAPPDATA": str(appdata / "Local"), + "XDG_CONFIG_HOME": str(staging / ".config"), + "XDG_CACHE_HOME": str(staging / ".cache"), + "NPM_CONFIG_CACHE": str(staging / ".npm"), + }) + return env + @staticmethod async def _run_subprocess( cmd: list[str], @@ -414,9 +429,7 @@ async def _install_from_safeskill(cls, source: str, scope: str) -> SkillInstallR with tempfile.TemporaryDirectory(prefix="flocks-safeskill-") as tmp: staging = Path(tmp) - env = os.environ.copy() - env["HOME"] = str(staging) - env["XDG_CONFIG_HOME"] = str(staging / ".config") + env = cls._safeskill_staging_env(staging) cmd = [ npx, "-y", diff --git a/tests/skill/test_installer.py b/tests/skill/test_installer.py index 63bf9c98b..87a7bfa56 100644 --- a/tests/skill/test_installer.py +++ b/tests/skill/test_installer.py @@ -454,6 +454,13 @@ async def fake_run_subprocess(cmd, *, timeout_sec, cwd=None, env=None): "--yes", ] assert captured["env"]["HOME"] == captured["cwd"] + assert captured["env"]["USERPROFILE"] == captured["cwd"] + staging = Path(captured["cwd"]) + assert captured["env"]["APPDATA"] == str(staging / "AppData" / "Roaming") + assert captured["env"]["LOCALAPPDATA"] == str(staging / "AppData" / "Local") + assert captured["env"]["XDG_CONFIG_HOME"] == str(staging / ".config") + assert captured["env"]["XDG_CACHE_HOME"] == str(staging / ".cache") + assert captured["env"]["NPM_CONFIG_CACHE"] == str(staging / ".npm") @pytest.mark.asyncio async def test_local_file(self, tmp_path: Path, tmp_skills_dir: Path): From b4833ed8a5a340196036e8949829f7ede7469842 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 2 Jul 2026 18:35:19 +0800 Subject: [PATCH 05/10] fix(skill): clean safeskill process trees on windows --- flocks/skill/installer.py | 43 +++++++++++++++++++++++++++++++---- tests/skill/test_installer.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/flocks/skill/installer.py b/flocks/skill/installer.py index bc0f89c5a..15a9aeac6 100644 --- a/flocks/skill/installer.py +++ b/flocks/skill/installer.py @@ -25,6 +25,7 @@ import re import shutil import signal +import subprocess import sys import tempfile import zipfile @@ -181,13 +182,37 @@ def _subprocess_stdio_kwargs( "stdout": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.PIPE, } - if os.name != "nt": + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: kwargs["start_new_session"] = True return kwargs @staticmethod - def _signal_process_tree(proc, sig: signal.Signals) -> None: + def _signal_process_tree(proc, sig: signal.Signals, *, force: bool = False) -> None: pid = getattr(proc, "pid", None) + if os.name == "nt": + if force and isinstance(pid, int) and pid > 0: + try: + completed = subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if completed.returncode == 0: + return + except Exception: + pass + try: + if force: + proc.kill() + else: + proc.terminate() + except ProcessLookupError: + pass + return + if os.name != "nt" and isinstance(pid, int) and pid > 0: try: os.killpg(pid, sig) @@ -197,7 +222,7 @@ def _signal_process_tree(proc, sig: signal.Signals) -> None: except Exception: pass try: - if sig == signal.SIGKILL: + if force: proc.kill() else: proc.terminate() @@ -241,7 +266,11 @@ async def _run_subprocess( try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: - SkillInstaller._signal_process_tree(proc, signal.SIGKILL) + SkillInstaller._signal_process_tree( + proc, + getattr(signal, "SIGKILL", signal.SIGTERM), + force=True, + ) try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: @@ -1273,7 +1302,11 @@ async def _execute_install_spec( try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: - cls._signal_process_tree(proc, signal.SIGKILL) + cls._signal_process_tree( + proc, + getattr(signal, "SIGKILL", signal.SIGTERM), + force=True, + ) try: await asyncio.wait_for(proc.communicate(), timeout=5) except Exception: diff --git a/tests/skill/test_installer.py b/tests/skill/test_installer.py index 87a7bfa56..6b9c841fc 100644 --- a/tests/skill/test_installer.py +++ b/tests/skill/test_installer.py @@ -6,6 +6,7 @@ import os import shutil import signal +import subprocess import tempfile import io import zipfile @@ -313,6 +314,20 @@ async def test_run_subprocess_isolates_stdio_and_process_group(self): if os.name != "nt": assert kwargs["start_new_session"] is True + def test_subprocess_stdio_kwargs_uses_windows_process_group(self): + with ( + patch("flocks.skill.installer.os.name", "nt"), + patch( + "flocks.skill.installer.subprocess.CREATE_NEW_PROCESS_GROUP", + 512, + create=True, + ), + ): + kwargs = SkillInstaller._subprocess_stdio_kwargs() + + assert kwargs["creationflags"] == 512 + assert "start_new_session" not in kwargs + @pytest.mark.asyncio async def test_run_subprocess_timeout_terminates_process_group(self): proc = MagicMock() @@ -342,6 +357,26 @@ async def fake_wait_for(awaitable, timeout): mock_killpg.assert_called_once_with(12345, signal.SIGTERM) + def test_signal_process_tree_force_uses_windows_taskkill(self): + proc = MagicMock() + proc.pid = 12345 + + with ( + patch("flocks.skill.installer.os.name", "nt"), + patch("flocks.skill.installer.subprocess.run") as mock_run, + ): + mock_run.return_value.returncode = 0 + SkillInstaller._signal_process_tree(proc, signal.SIGTERM, force=True) + + mock_run.assert_called_once_with( + ["taskkill", "/PID", "12345", "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + proc.kill.assert_not_called() + proc.terminate.assert_not_called() + @pytest.mark.asyncio async def test_skills_sh_cli_staging_imports_agent_skill(self, tmp_skills_dir): class Proc: From 11aecf7f8076fd9df4add6834e489ba2e2028d54 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 3 Jul 2026 12:03:16 +0800 Subject: [PATCH 06/10] wip --- flocks/console/login.py | 108 +++++++++++++++++- flocks/updater/updater.py | 62 +++++++++- .../test_updater_console_manifest_bundle.py | 13 ++- webui/src/components/common/UpdateModal.tsx | 71 ++++++++++-- webui/src/locales/en-US/update.json | 3 + webui/src/locales/zh-CN/update.json | 3 + 6 files changed, 245 insertions(+), 15 deletions(-) diff --git a/flocks/console/login.py b/flocks/console/login.py index ad79843c7..e224aa266 100644 --- a/flocks/console/login.py +++ b/flocks/console/login.py @@ -23,6 +23,31 @@ def _shared_console_session_path() -> Path: return Path(raw).expanduser() / "run" / "console-session.json" +def _flocks_root() -> Path: + return Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))).expanduser() + + +def _read_json_file(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _read_pro_bundle_marker() -> dict[str, Any]: + return _read_json_file(_flocks_root() / "run" / "pro-bundle-installed.json") + + +def _read_local_pro_license_id() -> str: + payload = _read_json_file(_flocks_root() / "flockspro" / "license.json") + return str(payload.get("license_id") or "").strip() + + +def _pending_pro_bundle_install_receipt_path() -> Path: + return _flocks_root() / "run" / "pro-bundle-install-receipt-pending.json" + + def _write_shared_console_session(session: dict[str, Any]) -> None: path = _shared_console_session_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -283,16 +308,54 @@ def _runtime_version() -> str: pass return str(__version__).lstrip("v") + @classmethod + def _runtime_version_info(cls) -> dict[str, str]: + marker = _read_pro_bundle_marker() + core_version = str( + marker.get("core_version") + or marker.get("oss_version") + or cls._runtime_version() + ).strip() + bundle_version = str( + marker.get("bundle_version") + or marker.get("installed_version") + or marker.get("display_version") + or "" + ).strip() + pro_component_version = str(marker.get("flockspro_component_version") or "").strip() + edition = cls._edition() + if edition != "flockspro" and (bundle_version or pro_component_version): + edition = "flockspro" + display_version = bundle_version if edition == "flockspro" and bundle_version else core_version + payload = { + "edition": edition, + "version": display_version, + "core_version": core_version, + } + if bundle_version: + payload["bundle_version"] = bundle_version + if pro_component_version: + payload["flockspro_component_version"] = pro_component_version + return {key: value for key, value in payload.items() if value} + @classmethod async def send_heartbeat(cls) -> dict[str, Any]: session = await cls._require_session() console_base = cls.console_base_url() + version_info = cls._runtime_version_info() payload = { "fingerprint": session["fingerprint"], "install_id": session["install_id"], "console_login_id": session.get("console_login_id"), "sent_at": _now_iso(), "status": "ok", + "license_id": _read_local_pro_license_id() or None, + "edition": version_info.get("edition"), + "version": version_info.get("version"), + "bundle_version": version_info.get("bundle_version"), + "core_version": version_info.get("core_version"), + "flockspro_component_version": version_info.get("flockspro_component_version"), + "version_info": version_info, } if not console_base: return {"ok": True, "mode": "mock", "node": payload} @@ -305,18 +368,57 @@ async def send_heartbeat(cls) -> dict[str, Any]: if resp.status_code in {401, 403}: raise ValueError("console 会话已失效,请重新登录") resp.raise_for_status() - return resp.json() + data = resp.json() + await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) + return data + + @classmethod + async def _report_pending_pro_bundle_install_receipt( + cls, + *, + client: httpx.AsyncClient, + session: dict[str, Any], + ) -> None: + console_base = cls.console_base_url() + token = str(session.get("console_session_token") or "").strip() + if not console_base or not token: + return + path = _pending_pro_bundle_install_receipt_path() + payload = _read_json_file(path) + if not payload: + return + payload = { + **payload, + "fingerprint": session.get("fingerprint"), + "install_id": session.get("install_id"), + "license_id": payload.get("license_id") or _read_local_pro_license_id() or None, + } + try: + resp = await client.post( + f"{console_base}/v1/pro-bundles/installations", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + if resp.status_code in {200, 201, 202}: + path.unlink(missing_ok=True) + except Exception: + return @classmethod async def sync_node_profile(cls, *, force: bool = False, source: str = "scheduled") -> dict[str, Any]: _ = force session = await cls._require_session() console_base = cls.console_base_url() + version_info = cls._runtime_version_info() payload = { "fingerprint": session["fingerprint"], "install_id": session["install_id"], - "edition": cls._edition(), - "version": cls._runtime_version(), + "edition": version_info.get("edition") or cls._edition(), + "version": version_info.get("version") or cls._runtime_version(), + "bundle_version": version_info.get("bundle_version"), + "core_version": version_info.get("core_version"), + "flockspro_component_version": version_info.get("flockspro_component_version"), + "version_info": version_info, "source": source, "sent_at": _now_iso(), } diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index d076af229..9fa4dac40 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -31,7 +31,7 @@ from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from typing import Any, AsyncGenerator, Awaitable, Callable -from urllib.parse import quote, urlparse +from urllib.parse import quote, urlencode, urlparse import httpx @@ -1222,6 +1222,17 @@ async def _fetch_gitlab_release( ) +def _read_local_pro_license_id() -> str: + license_path = _flocks_root() / "flockspro" / "license.json" + try: + payload = json.loads(license_path.read_text(encoding="utf-8")) + except Exception: + return "" + if not isinstance(payload, dict): + return "" + return str(payload.get("license_id") or "").strip() + + async def _load_console_session_token() -> str | None: def _token_from_payload(payload: Any) -> str | None: if not isinstance(payload, dict): @@ -1273,8 +1284,14 @@ async def _fetch_console_manifest_release_info(console_session_token: str | None raise ValueError("FLOCKS_CONSOLE_BASE_URL 未配置,无法使用 console-manifest 源") channel = (os.getenv("FLOCKS_UPDATE_CHANNEL") or "flockspro").strip() or "flockspro" - url = f"{manifest_base}/v1/manifest/latest?channel={channel}" + license_id = (os.getenv("FLOCKSPRO_LICENSE_ID") or _read_local_pro_license_id()).strip() + query = {"channel": channel} + if license_id: + query["license_id"] = license_id + url = f"{manifest_base}/v1/manifest/latest?{urlencode(query)}" headers: dict[str, str] = {} + if license_id: + headers["x-license-id"] = license_id token = str(console_session_token or "").strip() or await _load_console_session_token() if token: headers["Authorization"] = f"Bearer {token}" @@ -1879,6 +1896,47 @@ def _write_pro_bundle_install_marker(manifest: dict[str, Any], *, bundle_sha256: "installed_at": datetime.now(timezone.utc).isoformat(), } marker.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + _write_pending_pro_bundle_install_receipt(payload) + + +def _write_pending_pro_bundle_install_receipt(marker_payload: dict[str, Any]) -> None: + receipt_path = _flocks_root() / "run" / "pro-bundle-install-receipt-pending.json" + receipt_path.parent.mkdir(parents=True, exist_ok=True) + bundle_version = str( + marker_payload.get("bundle_version") + or marker_payload.get("installed_version") + or marker_payload.get("display_version") + or "" + ).strip() + core_version = str(marker_payload.get("core_version") or marker_payload.get("oss_version") or "").strip() + pro_component_version = str(marker_payload.get("flockspro_component_version") or "").strip() + version_info = { + "edition": "flockspro", + "version": bundle_version, + "bundle_version": bundle_version, + "core_version": core_version, + "flockspro_component_version": pro_component_version, + } + receipt = { + "release_id": marker_payload.get("release_id") or marker_payload.get("bundle_release_id"), + "bundle_release_id": marker_payload.get("bundle_release_id") or marker_payload.get("release_id"), + "license_id": _read_local_pro_license_id() or None, + "installed_version": bundle_version, + "bundle_version": bundle_version, + "target_version": bundle_version, + "core_version": core_version, + "oss_version": core_version, + "flockspro_component_version": pro_component_version, + "build_id": marker_payload.get("build_id"), + "install_result": "success", + "reported_at": datetime.now(timezone.utc).isoformat(), + "version_info": {key: value for key, value in version_info.items() if value}, + } + receipt_path.write_text(json.dumps(receipt, ensure_ascii=True, sort_keys=True), encoding="utf-8") + try: + os.chmod(receipt_path, 0o600) + except OSError: + pass class _NullConsole: diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py index 518f3b862..90f8e7a78 100644 --- a/tests/updater/test_updater_console_manifest_bundle.py +++ b/tests/updater/test_updater_console_manifest_bundle.py @@ -15,6 +15,9 @@ async def test_fetch_console_manifest_release_uses_bundle_url(monkeypatch: pytes from flocks.storage.storage import Storage monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text('{"license_id": "lic_manifest"}', encoding="utf-8") await Storage.set("console:session", {"console_session_token": "cs_manifest"}, "json") class _Resp: @@ -41,7 +44,11 @@ async def __aexit__(self, exc_type, exc, tb): async def get(self, url, headers=None, follow_redirects=True): assert "channel=flockspro" in url - assert headers == {"Authorization": "Bearer cs_manifest"} + assert "license_id=lic_manifest" in url + assert headers == { + "x-license-id": "lic_manifest", + "Authorization": "Bearer cs_manifest", + } return _Resp() monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") @@ -294,6 +301,10 @@ def test_console_manifest_release_identity_writes_product_and_core_versions( assert marker["oss_version"] == "v2026.6.21" assert marker["flockspro_component_version"] == "v2026.6.23" assert marker["build_id"] == "job_623" + pending = json.loads((tmp_path / "run" / "pro-bundle-install-receipt-pending.json").read_text(encoding="utf-8")) + assert pending["install_result"] == "success" + assert pending["bundle_version"] == "v2026.6.23" + assert pending["version_info"]["core_version"] == "v2026.6.21" @pytest.mark.asyncio diff --git a/webui/src/components/common/UpdateModal.tsx b/webui/src/components/common/UpdateModal.tsx index fbfbdb12b..ab312d31f 100644 --- a/webui/src/components/common/UpdateModal.tsx +++ b/webui/src/components/common/UpdateModal.tsx @@ -29,6 +29,28 @@ function formatUpdateVersion(version?: string | null): string { return /^(pro-)?v/i.test(raw) ? raw : `v${raw}`; } +function clampPercent(value?: number | null): number | null { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + return Math.max(0, Math.min(100, Math.round(value))); +} + +function formatBytes(value?: number | null): string { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return '—'; + } + const units = ['B', 'KB', 'MB', 'GB']; + let size = value; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + const precision = unitIndex === 0 || size >= 10 ? 0 : 1; + return `${size.toFixed(precision)} ${units[unitIndex]}`; +} + interface UpdateModalProps { initialInfo?: VersionInfo | null; edition?: UpdateEdition; @@ -152,22 +174,53 @@ export default function UpdateModal({ initialInfo, edition = 'flocks', canUpgrad }; const renderStep = (step: UpdateProgress, index: number) => { - const label = t(`stageLabels.${step.stage}`, { defaultValue: step.stage }); + const label = edition === 'flockspro' && step.stage === 'fetching' + ? t('stageLabels.fetchingPro') + : t(`stageLabels.${step.stage}`, { defaultValue: step.stage }); const isError = step.stage === 'error'; const isSpinning = step.stage === 'restarting'; + const downloadPercent = clampPercent(step.percent); + const hasDownloadProgress = step.stage === 'fetching' && typeof step.downloaded_bytes === 'number'; const detail = step.pro_component_filename || step.bundle_filename || step.message; return ( -
+
{isError - ? + ? : isSpinning - ? - : + ? + : } - {label} - {!isError && !isSpinning && ( - {detail} - )} +
+
+ {label} + {!isError && !isSpinning && ( + {detail} + )} +
+ {hasDownloadProgress && ( +
+
+ {downloadPercent === null + ? t('downloadProgressUnknown', { downloaded: formatBytes(step.downloaded_bytes) }) + : t('downloadProgressLabel', { + percent: downloadPercent, + downloaded: formatBytes(step.downloaded_bytes), + total: formatBytes(step.total_bytes), + })} +
+
+
+
+
+ )} +
); }; diff --git a/webui/src/locales/en-US/update.json b/webui/src/locales/en-US/update.json index be271bbe1..e775bc060 100644 --- a/webui/src/locales/en-US/update.json +++ b/webui/src/locales/en-US/update.json @@ -22,6 +22,8 @@ "upgradeTo": "Upgrade to {{version}}", "upgrading": "Upgrading...", "waitingRestart": "Waiting for restart...", + "downloadProgressLabel": "{{percent}}% · {{downloaded}} / {{total}}", + "downloadProgressUnknown": "Downloaded {{downloaded}}", "checkFailed": "Failed to check version", "upgradeFailed": "Upgrade failed", "restartTimeout": "Service readiness check timed out: {{reason}}. Please refresh manually to confirm status.", @@ -32,6 +34,7 @@ "dockerUpgradeHint": "docker pull ghcr.io/agentflocks/flocks:latest", "stageLabels": { "fetching": "Downloading latest source archive", + "fetchingPro": "Downloading Pro bundle", "backing_up": "Backing up current version", "applying": "Applying new version", "syncing": "Syncing backend dependencies", diff --git a/webui/src/locales/zh-CN/update.json b/webui/src/locales/zh-CN/update.json index c26ebbe60..5d8e90af9 100644 --- a/webui/src/locales/zh-CN/update.json +++ b/webui/src/locales/zh-CN/update.json @@ -22,6 +22,8 @@ "upgradeTo": "升级到 {{version}}", "upgrading": "升级中...", "waitingRestart": "等待重启...", + "downloadProgressLabel": "{{percent}}% · {{downloaded}} / {{total}}", + "downloadProgressUnknown": "已下载 {{downloaded}}", "checkFailed": "检查版本失败", "upgradeFailed": "升级失败", "restartTimeout": "服务恢复确认超时:{{reason}}。请手动刷新页面确认状态。", @@ -32,6 +34,7 @@ "dockerUpgradeHint": "docker pull ghcr.io/agentflocks/flocks:latest", "stageLabels": { "fetching": "下载最新源码包", + "fetchingPro": "下载 Pro bundle", "backing_up": "备份当前版本", "applying": "应用新版本", "syncing": "同步后端依赖", From 6518fed958ab6004d77691f8a9047a395c4d42e1 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 3 Jul 2026 13:35:50 +0800 Subject: [PATCH 07/10] wip --- flocks/console/login.py | 148 +++++++++++++++--- flocks/server/routes/flockspro_license.py | 3 + pyproject.toml | 2 +- tests/console/test_console_login_heartbeat.py | 136 ++++++++++++++++ .../routes/test_console_upgrade_routes.py | 44 ++++++ 5 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 tests/console/test_console_login_heartbeat.py diff --git a/flocks/console/login.py b/flocks/console/login.py index e224aa266..5623637c4 100644 --- a/flocks/console/login.py +++ b/flocks/console/login.py @@ -39,15 +39,64 @@ def _read_pro_bundle_marker() -> dict[str, Any]: return _read_json_file(_flocks_root() / "run" / "pro-bundle-installed.json") +def _local_pro_license_path() -> Path: + return _flocks_root() / "flockspro" / "license.json" + + +def _read_local_pro_license_state() -> dict[str, Any]: + return _read_json_file(_local_pro_license_path()) + + def _read_local_pro_license_id() -> str: - payload = _read_json_file(_flocks_root() / "flockspro" / "license.json") - return str(payload.get("license_id") or "").strip() + state = _read_local_pro_license_state() + payload = state.get("payload") if isinstance(state.get("payload"), dict) else {} + return str(state.get("license_id") or payload.get("license_id") or "").strip() + + +def _read_local_pro_license_status() -> str: + state = _read_local_pro_license_state() + payload = state.get("payload") if isinstance(state.get("payload"), dict) else {} + return str( + state.get("license_status") + or state.get("status") + or payload.get("license_status") + or payload.get("status") + or "" + ).strip() def _pending_pro_bundle_install_receipt_path() -> Path: return _flocks_root() / "run" / "pro-bundle-install-receipt-pending.json" +def _sync_local_pro_license_from_heartbeat_response(data: dict[str, Any]) -> None: + license_path = _local_pro_license_path() + state = _read_json_file(license_path) + now_ts = int(datetime.now(UTC).timestamp()) + if state: + changed = False + patch_token = data.get("license_patch") or data.get("latest_patch") + if isinstance(patch_token, str) and patch_token: + patches = state.get("patches") if isinstance(state.get("patches"), list) else [] + if patch_token not in patches: + state["patches"] = [*patches, patch_token] + changed = True + if state.get("last_sync_at") != now_ts: + state["last_sync_at"] = now_ts + state["last_heartbeat_ok_at"] = now_ts + changed = True + if changed: + license_path.parent.mkdir(parents=True, exist_ok=True) + license_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + revoked_license_ids = data.get("revoked_license_ids") + if isinstance(revoked_license_ids, list): + revocation_path = _flocks_root() / "flockspro" / "revocation.json" + revocation_path.parent.mkdir(parents=True, exist_ok=True) + payload = {"revoked_license_ids": sorted({str(item) for item in revoked_license_ids})} + revocation_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _write_shared_console_session(session: dict[str, Any]) -> None: path = _shared_console_session_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -67,6 +116,29 @@ def _write_shared_console_session(session: dict[str, Any]) -> None: pass +def read_shared_console_session() -> dict[str, Any] | None: + path = _shared_console_session_path() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + token = str(payload.get("console_session_token") or "").strip() + fingerprint = str(payload.get("fingerprint") or "").strip() + install_id = str(payload.get("install_id") or "").strip() + if not token or not fingerprint or not install_id: + return None + expires_at = str(payload.get("expires_at") or "").strip() + if expires_at: + try: + if _parse_iso(expires_at) <= datetime.now(UTC): + return None + except ValueError: + return None + return payload + + def _delete_shared_console_session() -> None: path = _shared_console_session_path() try: @@ -309,7 +381,7 @@ def _runtime_version() -> str: return str(__version__).lstrip("v") @classmethod - def _runtime_version_info(cls) -> dict[str, str]: + def runtime_version_info(cls, *, pro_component_version: str | None = None) -> dict[str, str]: marker = _read_pro_bundle_marker() core_version = str( marker.get("core_version") @@ -322,7 +394,7 @@ def _runtime_version_info(cls) -> dict[str, str]: or marker.get("display_version") or "" ).strip() - pro_component_version = str(marker.get("flockspro_component_version") or "").strip() + pro_component_version = str(marker.get("flockspro_component_version") or pro_component_version or "").strip() edition = cls._edition() if edition != "flockspro" and (bundle_version or pro_component_version): edition = "flockspro" @@ -339,17 +411,22 @@ def _runtime_version_info(cls) -> dict[str, str]: return {key: value for key, value in payload.items() if value} @classmethod - async def send_heartbeat(cls) -> dict[str, Any]: - session = await cls._require_session() - console_base = cls.console_base_url() - version_info = cls._runtime_version_info() - payload = { - "fingerprint": session["fingerprint"], - "install_id": session["install_id"], + def heartbeat_payload( + cls, + session: dict[str, Any], + *, + status: str = "ok", + license_id: str | None = None, + pro_component_version: str | None = None, + ) -> dict[str, Any]: + version_info = cls.runtime_version_info(pro_component_version=pro_component_version) + return { + "fingerprint": session.get("fingerprint"), + "install_id": session.get("install_id"), "console_login_id": session.get("console_login_id"), "sent_at": _now_iso(), - "status": "ok", - "license_id": _read_local_pro_license_id() or None, + "status": status, + "license_id": license_id or None, "edition": version_info.get("edition"), "version": version_info.get("version"), "bundle_version": version_info.get("bundle_version"), @@ -357,21 +434,56 @@ async def send_heartbeat(cls) -> dict[str, Any]: "flockspro_component_version": version_info.get("flockspro_component_version"), "version_info": version_info, } - if not console_base: + + @classmethod + async def send_heartbeat_for_session( + cls, + *, + session: dict[str, Any], + status: str = "ok", + license_id: str | None = None, + heartbeat_url: str | None = None, + report_install_receipt: bool = False, + pro_component_version: str | None = None, + ) -> dict[str, Any]: + console_base = cls.console_base_url() + payload = cls.heartbeat_payload( + session, + status=status, + license_id=license_id, + pro_component_version=pro_component_version, + ) + target_url = heartbeat_url or (f"{console_base}/v1/heartbeats" if console_base else "") + if not target_url: return {"ok": True, "mode": "mock", "node": payload} + token = str(session.get("console_session_token") or "").strip() + if not token: + raise ValueError("console_session_token 缺失,无法发送心跳") async with httpx.AsyncClient(timeout=10) as client: resp = await client.post( - f"{console_base}/v1/heartbeats", + target_url, json=payload, - headers={"Authorization": f"Bearer {session['console_session_token']}"}, + headers={"Authorization": f"Bearer {token}"}, ) if resp.status_code in {401, 403}: raise ValueError("console 会话已失效,请重新登录") resp.raise_for_status() data = resp.json() - await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) + _sync_local_pro_license_from_heartbeat_response(data) + if report_install_receipt: + await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) return data + @classmethod + async def send_heartbeat(cls) -> dict[str, Any]: + session = await cls._require_session() + return await cls.send_heartbeat_for_session( + session=session, + status=_read_local_pro_license_status() or "ok", + license_id=_read_local_pro_license_id() or None, + report_install_receipt=True, + ) + @classmethod async def _report_pending_pro_bundle_install_receipt( cls, @@ -409,7 +521,7 @@ async def sync_node_profile(cls, *, force: bool = False, source: str = "schedule _ = force session = await cls._require_session() console_base = cls.console_base_url() - version_info = cls._runtime_version_info() + version_info = cls.runtime_version_info() payload = { "fingerprint": session["fingerprint"], "install_id": session["install_id"], diff --git a/flocks/server/routes/flockspro_license.py b/flocks/server/routes/flockspro_license.py index 10b132273..b7f041b89 100644 --- a/flocks/server/routes/flockspro_license.py +++ b/flocks/server/routes/flockspro_license.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Request +from flocks.console.login import ConsoleLoginService from flocks.server.auth import require_user from flocks.server.routes.console_upgrade import _get_pro_capability_status, _is_pro_component_installed @@ -50,6 +51,8 @@ async def refresh_flockspro_license_status(request: Request) -> dict[str, Any]: return _inactive_status("flockspro_not_installed") try: + await ConsoleLoginService.send_heartbeat() + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] checker = get_license_checker() diff --git a/pyproject.toml b/pyproject.toml index 81ebf74ef..b3bbacd8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flocks" -version = "v2026.7.1" +version = "v2026.7.3.3" description = "AI-Native SecOps platform with multi-agent collaboration" authors = [ {name = "Flocks Team", email = "team@example.com"} diff --git a/tests/console/test_console_login_heartbeat.py b/tests/console/test_console_login_heartbeat.py new file mode 100644 index 000000000..bd143536f --- /dev/null +++ b/tests/console/test_console_login_heartbeat.py @@ -0,0 +1,136 @@ +import asyncio +import json + +from flocks.console import login as login_mod +from flocks.console.login import ConsoleLoginService + + +def test_heartbeat_payload_includes_runtime_version_info(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3")) + + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + json.dumps( + { + "bundle_version": "v2026.7.3", + "core_version": "v2026.7.3", + } + ), + encoding="utf-8", + ) + + payload = ConsoleLoginService.heartbeat_payload( + { + "console_session_token": "cs_heartbeat", + "fingerprint": "fp_heartbeat", + "install_id": "inst_heartbeat", + }, + status="poc", + license_id="lic_heartbeat", + pro_component_version="2026.7.3.1", + ) + + assert payload["fingerprint"] == "fp_heartbeat" + assert payload["install_id"] == "inst_heartbeat" + assert payload["status"] == "poc" + assert payload["license_id"] == "lic_heartbeat" + assert payload["edition"] == "flockspro" + assert payload["version"] == "v2026.7.3" + assert payload["bundle_version"] == "v2026.7.3" + assert payload["core_version"] == "v2026.7.3" + assert payload["flockspro_component_version"] == "2026.7.3.1" + assert payload["version_info"] == { + "edition": "flockspro", + "version": "v2026.7.3", + "bundle_version": "v2026.7.3", + "core_version": "v2026.7.3", + "flockspro_component_version": "2026.7.3.1", + } + + +def test_send_heartbeat_uses_local_pro_license_and_applies_response(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3")) + + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text( + json.dumps( + { + "license_id": "lic_core", + "payload": {"license_id": "lic_core", "status": "poc"}, + "patches": [], + } + ), + encoding="utf-8", + ) + marker = tmp_path / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True) + marker.write_text( + json.dumps( + { + "bundle_version": "v2026.7.3", + "core_version": "v2026.7.3", + "flockspro_component_version": "2026.7.3.1", + } + ), + encoding="utf-8", + ) + + async def _require_session(cls): + return { + "console_session_token": "cs_core", + "fingerprint": "fp_core", + "install_id": "inst_core", + } + + captured: dict[str, object] = {} + + class _Response: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return { + "license_patch": "patch_token_1", + "revoked_license_ids": ["lic_revoked"], + } + + class _Client: + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _Response() + + monkeypatch.setattr(ConsoleLoginService, "_require_session", classmethod(_require_session)) + monkeypatch.setattr(login_mod.httpx, "AsyncClient", _Client) + + asyncio.run(ConsoleLoginService.send_heartbeat()) + + assert captured["url"] == "https://console.example.com/v1/heartbeats" + assert captured["headers"] == {"Authorization": "Bearer cs_core"} + payload = captured["json"] + assert payload["status"] == "poc" + assert payload["license_id"] == "lic_core" + assert payload["version_info"]["bundle_version"] == "v2026.7.3" + + updated = json.loads(license_path.read_text(encoding="utf-8")) + assert updated["patches"] == ["patch_token_1"] + assert updated["last_sync_at"] + revocation = json.loads((tmp_path / "flockspro" / "revocation.json").read_text(encoding="utf-8")) + assert revocation == {"revoked_license_ids": ["lic_revoked"]} diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py index 575e1e10e..5012946c6 100644 --- a/tests/server/routes/test_console_upgrade_routes.py +++ b/tests/server/routes/test_console_upgrade_routes.py @@ -337,6 +337,50 @@ async def test_flockspro_license_status_delegates_to_pro_runtime(monkeypatch: py assert payload["license_id"] == "lic_1" +async def test_flockspro_license_refresh_sends_heartbeat_from_core(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import flockspro_license as license_routes + + app = FastAPI() + app.include_router(license_routes.router, prefix="/api/flockspro/license") + monkeypatch.setattr(license_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + license_routes, + "_get_pro_capability_status", + lambda: {"active": True, "pro_enabled": True, "license_status": "poc", "license_id": "lic_1"}, + ) + monkeypatch.setattr(license_routes, "require_user", lambda _req: _mock_admin()) + + heartbeat_calls: list[str] = [] + refresh_calls: list[str] = [] + + async def _send_heartbeat(): + heartbeat_calls.append("sent") + return {"ok": True} + + class _Checker: + async def refresh(self): + refresh_calls.append("refreshed") + return {"active": True} + + runtime_module = ModuleType("flockspro.license.runtime") + runtime_module.get_license_checker = lambda: _Checker() + license_module = ModuleType("flockspro.license") + flockspro_module = ModuleType("flockspro") + monkeypatch.setitem(__import__("sys").modules, "flockspro", flockspro_module) + monkeypatch.setitem(__import__("sys").modules, "flockspro.license", license_module) + monkeypatch.setitem(__import__("sys").modules, "flockspro.license.runtime", runtime_module) + monkeypatch.setattr(license_routes.ConsoleLoginService, "send_heartbeat", _send_heartbeat) + + transport = httpx.ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as local_client: + resp = await local_client.post("/api/flockspro/license/refresh") + + assert resp.status_code == status.HTTP_200_OK + assert heartbeat_calls == ["sent"] + assert refresh_calls == ["refreshed"] + assert resp.json()["license_id"] == "lic_1" + + async def test_create_upgrade_request_does_not_link_previous_request_when_omitted( client: AsyncClient, monkeypatch: pytest.MonkeyPatch, From 1e9f637551415c7285509223cde8a7e60ecbe906 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 3 Jul 2026 18:46:39 +0800 Subject: [PATCH 08/10] wip --- flocks/console/login.py | 37 +++----- flocks/server/routes/console_upgrade.py | 69 +++++++-------- flocks/updater/updater.py | 88 +++++-------------- tests/console/test_console_login_heartbeat.py | 39 +++++--- .../routes/test_console_upgrade_routes.py | 48 +++++----- .../test_updater_console_manifest_bundle.py | 71 ++++++++------- tests/updater/test_updater_edition_sources.py | 3 +- 7 files changed, 158 insertions(+), 197 deletions(-) diff --git a/flocks/console/login.py b/flocks/console/login.py index 5623637c4..7bfeaf6d5 100644 --- a/flocks/console/login.py +++ b/flocks/console/login.py @@ -381,32 +381,27 @@ def _runtime_version() -> str: return str(__version__).lstrip("v") @classmethod - def runtime_version_info(cls, *, pro_component_version: str | None = None) -> dict[str, str]: + def runtime_version_payload(cls, *, pro_component_version: str | None = None) -> dict[str, str]: marker = _read_pro_bundle_marker() core_version = str( marker.get("core_version") - or marker.get("oss_version") or cls._runtime_version() ).strip() bundle_version = str( marker.get("bundle_version") - or marker.get("installed_version") - or marker.get("display_version") or "" ).strip() pro_component_version = str(marker.get("flockspro_component_version") or pro_component_version or "").strip() - edition = cls._edition() - if edition != "flockspro" and (bundle_version or pro_component_version): - edition = "flockspro" - display_version = bundle_version if edition == "flockspro" and bundle_version else core_version + has_pro_bundle = bool(bundle_version or pro_component_version) + edition = "flockspro" if has_pro_bundle else "oss" payload = { "edition": edition, - "version": display_version, - "core_version": core_version, } - if bundle_version: + if core_version: + payload["core_version"] = core_version + if edition == "flockspro" and bundle_version: payload["bundle_version"] = bundle_version - if pro_component_version: + if edition == "flockspro" and pro_component_version: payload["flockspro_component_version"] = pro_component_version return {key: value for key, value in payload.items() if value} @@ -419,7 +414,7 @@ def heartbeat_payload( license_id: str | None = None, pro_component_version: str | None = None, ) -> dict[str, Any]: - version_info = cls.runtime_version_info(pro_component_version=pro_component_version) + version_payload = cls.runtime_version_payload(pro_component_version=pro_component_version) return { "fingerprint": session.get("fingerprint"), "install_id": session.get("install_id"), @@ -427,12 +422,7 @@ def heartbeat_payload( "sent_at": _now_iso(), "status": status, "license_id": license_id or None, - "edition": version_info.get("edition"), - "version": version_info.get("version"), - "bundle_version": version_info.get("bundle_version"), - "core_version": version_info.get("core_version"), - "flockspro_component_version": version_info.get("flockspro_component_version"), - "version_info": version_info, + **version_payload, } @classmethod @@ -521,16 +511,11 @@ async def sync_node_profile(cls, *, force: bool = False, source: str = "schedule _ = force session = await cls._require_session() console_base = cls.console_base_url() - version_info = cls.runtime_version_info() + version_payload = cls.runtime_version_payload() payload = { "fingerprint": session["fingerprint"], "install_id": session["install_id"], - "edition": version_info.get("edition") or cls._edition(), - "version": version_info.get("version") or cls._runtime_version(), - "bundle_version": version_info.get("bundle_version"), - "core_version": version_info.get("core_version"), - "flockspro_component_version": version_info.get("flockspro_component_version"), - "version_info": version_info, + **version_payload, "source": source, "sent_at": _now_iso(), } diff --git a/flocks/server/routes/console_upgrade.py b/flocks/server/routes/console_upgrade.py index 9539bf71a..6fb12cd26 100644 --- a/flocks/server/routes/console_upgrade.py +++ b/flocks/server/routes/console_upgrade.py @@ -317,8 +317,8 @@ def _enrich_record_from_install_marker(record: dict[str, Any]) -> dict[str, Any] marker = _read_pro_bundle_install_marker() if marker: details.setdefault("auto_install_release_id", marker.get("release_id") or marker.get("bundle_release_id")) - details.setdefault("auto_install_version", marker.get("installed_version")) - details.setdefault("auto_install_pro_version", marker.get("flockspro_component_version")) + details.setdefault("auto_install_bundle_version", marker.get("bundle_version")) + details.setdefault("auto_install_pro_component_version", marker.get("flockspro_component_version")) details.setdefault("flockspro_component_version", marker.get("flockspro_component_version")) details.setdefault("auto_install_build_id", marker.get("build_id")) @@ -442,19 +442,16 @@ def _record_target_bundle(record: dict[str, Any]) -> dict[str, str]: "release_id": release_id, "bundle_release_id": _clean_bundle_value(details.get("bundle_release_id") or release_id), "build_id": _clean_bundle_value(details.get("target_build_id") or latest_bundle.get("build_id")), - "display_version": _clean_bundle_value( - details.get("target_display_version") - or details.get("auto_install_target") - or latest_bundle.get("display_version") + "bundle_version_update_to": _clean_bundle_value( + details.get("bundle_version_update_to") + or latest_bundle.get("bundle_version") ), - "core_version": _clean_bundle_value( - details.get("target_core_version") - or details.get("target_oss_version") + "core_version_update_to": _clean_bundle_value( + details.get("core_version_update_to") or latest_bundle.get("core_version") - or latest_bundle.get("oss_version") ), - "flockspro_component_version": _clean_bundle_value( - details.get("target_flockspro_component_version") + "flockspro_component_version_update_to": _clean_bundle_value( + details.get("flockspro_component_version_update_to") or latest_bundle.get("flockspro_component_version") ), } @@ -467,18 +464,18 @@ def _target_bundle_fingerprint_matches(target: dict[str, str], marker: dict[str, if build_id and marker_build_id: return marker_build_id == build_id - pro_version = target.get("flockspro_component_version") + pro_version = target.get("flockspro_component_version_update_to") marker_pro_version = _clean_bundle_value(marker.get("flockspro_component_version")) if pro_version and marker_pro_version: return marker_pro_version == pro_version - display_version = target.get("display_version") - marker_display_version = _clean_bundle_value(marker.get("installed_version") or marker.get("display_version")) - if display_version and marker_display_version: - return _clean_version_value(marker_display_version) == _clean_version_value(display_version) + bundle_version = target.get("bundle_version_update_to") + marker_bundle_version = _clean_bundle_value(marker.get("bundle_version")) + if bundle_version and marker_bundle_version: + return _clean_version_value(marker_bundle_version) == _clean_version_value(bundle_version) - core_version = target.get("core_version") or target.get("oss_version") - marker_core_version = _clean_bundle_value(marker.get("core_version") or marker.get("oss_version")) + core_version = target.get("core_version_update_to") + marker_core_version = _clean_bundle_value(marker.get("core_version")) if core_version and marker_core_version: return _clean_version_value(marker_core_version) == _clean_version_value(core_version) @@ -510,7 +507,7 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: marker = _read_pro_bundle_install_marker() if _is_pro_component_installed() and _marker_matches_target_bundle(marker, record): details["auto_install_release_id"] = marker.get("release_id") or marker.get("bundle_release_id") - details["auto_install_version"] = marker.get("installed_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") await _maybe_activate_pro_license(record, allow_fallback=False) await _maybe_refresh_pro_license(record) capability = _record_pro_capability(details) @@ -538,8 +535,8 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: "done" if final_stage == "done" and capability.get("pro_enabled") else "license_inactive" ) details["auto_install_release_id"] = marker.get("release_id") or marker.get("bundle_release_id") - details["auto_install_version"] = marker.get("installed_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = final_message _enrich_record_from_install_marker(record) @@ -565,7 +562,7 @@ def _marker_indicates_pro_bundle_installed(marker: dict[str, Any]) -> bool: return False return any( str(marker.get(key) or "").strip() - for key in ("installed_at", "installed_version", "bundle_version", "flockspro_component_version", "build_id") + for key in ("installed_at", "bundle_version", "flockspro_component_version", "build_id") ) @@ -600,15 +597,12 @@ async def _report_pro_bundle_installation( "license_id": _record_license_id(record), "fingerprint": console_session.get("fingerprint"), "install_id": console_session.get("install_id"), - "installed_version": source.get("installed_version") - or source.get("display_version") - or target.get("display_version") - or details.get("auto_install_target") - or details.get("auto_install_version") - or "", - "core_version": source.get("core_version") or source.get("oss_version") or target.get("core_version"), - "oss_version": source.get("core_version") or source.get("oss_version") or target.get("core_version"), - "flockspro_component_version": source.get("flockspro_component_version") or target.get("flockspro_component_version"), + "bundle_version": source.get("bundle_version") or target.get("bundle_version_update_to") or "", + "core_version": source.get("core_version") or target.get("core_version_update_to"), + "flockspro_component_version": ( + source.get("flockspro_component_version") + or target.get("flockspro_component_version_update_to") + ), "build_id": source.get("build_id") or target.get("build_id"), "install_result": install_result, "error_message": error_message, @@ -688,8 +682,8 @@ async def _finalize_restarting_upgrade_if_installed(record: dict[str, Any]) -> d await _maybe_refresh_pro_license(record) capability = _record_pro_capability(details) details["auto_install_result"] = "done" if capability.get("pro_enabled") else "license_inactive" - details["auto_install_version"] = marker.get("installed_version") or marker.get("display_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = "Upgrade completed after service restart" _enrich_record_from_install_marker(record) @@ -877,7 +871,8 @@ async def get_pro_package_status(request: Request) -> dict[str, Any]: "installed": installed, "runtime_importable": runtime_importable, "install_marker_present": install_marker_present, - "installed_version": marker.get("installed_version"), + "bundle_version": marker.get("bundle_version"), + "core_version": marker.get("core_version"), "flockspro_component_version": marker.get("flockspro_component_version"), "build_id": marker.get("build_id"), "installed_at": marker.get("installed_at"), @@ -996,8 +991,8 @@ async def _stream(): details["auto_install_result"] = "done" else: details["auto_install_result"] = "license_inactive" - details["auto_install_version"] = marker.get("installed_version") - details["auto_install_pro_version"] = marker.get("flockspro_component_version") + details["auto_install_bundle_version"] = marker.get("bundle_version") + details["auto_install_pro_component_version"] = marker.get("flockspro_component_version") details["auto_install_completed_at"] = datetime.now(UTC).isoformat() details["auto_install_message"] = progress.message _enrich_record_from_install_marker(raw) diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index 9fa4dac40..7337d6dbe 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -1082,31 +1082,15 @@ def _archive_format_for_url(url: str, manifest_format: str | None = None) -> str return "tar.gz" -def _console_manifest_display_version(data: dict[str, Any]) -> str: - display_version = str(data.get("display_version") or data.get("version") or data.get("latest_version") or "").strip() - if display_version: - return display_version - core_version = str(data.get("core_version") or "").strip() - if core_version: - return core_version - oss_version = str(data.get("oss_version") or "").strip() - if oss_version: - return oss_version - compare_version = str(data.get("compare_version") or "").strip() - return f"v{compare_version}" if compare_version and not compare_version.startswith(("v", "V")) else compare_version +def _console_manifest_bundle_version(data: dict[str, Any]) -> str: + return str(data.get("bundle_version") or "").strip() def _pro_bundle_core_version(data: dict[str, Any]) -> str: - core_version = str(data.get("core_version") or "").strip() - if core_version: - return core_version - oss_version = str(data.get("oss_version") or "").strip() - if oss_version: - return oss_version - return _console_manifest_display_version(data) + return str(data.get("core_version") or "").strip() -def _pro_bundle_oss_version(data: dict[str, Any]) -> str: +def _pro_bundle_core_version_for_compare(data: dict[str, Any]) -> str: return _pro_bundle_core_version(data) @@ -1117,22 +1101,21 @@ def _version_label(version: str | None) -> str: return normalized if normalized.startswith(("v", "V")) else f"v{normalized}" -def _is_pro_bundle_oss_older_than_local(manifest: dict[str, Any], current_version: str | None = None) -> bool: - bundle_oss_version = _pro_bundle_oss_version(manifest) - if not bundle_oss_version: +def _is_pro_bundle_core_older_than_local(manifest: dict[str, Any], current_version: str | None = None) -> bool: + bundle_core_version = _pro_bundle_core_version_for_compare(manifest) + if not bundle_core_version: return False local_version = str(current_version or get_current_version() or "").strip() if not local_version: return False - return _parse_version(bundle_oss_version) < _parse_version(local_version) + return _parse_version(bundle_core_version) < _parse_version(local_version) -def _effective_pro_bundle_manifest(manifest: dict[str, Any], effective_oss_version: str) -> dict[str, Any]: +def _effective_pro_bundle_manifest(manifest: dict[str, Any], effective_core_version: str) -> dict[str, Any]: payload = dict(manifest) - effective_label = _version_label(effective_oss_version) + effective_label = _version_label(effective_core_version) if effective_label: payload["core_version"] = effective_label - payload["oss_version"] = effective_label return payload @@ -1314,9 +1297,9 @@ async def _fetch_console_manifest_release_info(console_session_token: str | None if datetime.now(timezone.utc) < frozen_until: raise ValueError("console manifest channel frozen_until not reached") - latest = _console_manifest_display_version(data) + latest = _console_manifest_bundle_version(data) if not latest: - raise ValueError("manifest 响应缺少 compare_version/display_version") + raise ValueError("manifest 响应缺少 bundle_version") bundle_url = str( data.get("bundle_url") or data.get("url") @@ -1865,13 +1848,12 @@ def _merge_console_manifest_release_identity( merged["release_id"] = release_id if bundle_release_id and not merged.get("bundle_release_id"): merged["bundle_release_id"] = bundle_release_id - for key in ("display_version", "version", "latest_version", "compare_version", "flockspro_component_version", "build_id"): + for key in ("bundle_version", "compare_version", "flockspro_component_version", "build_id"): if console_manifest.get(key): merged[key] = console_manifest.get(key) - core_version = console_manifest.get("core_version") or console_manifest.get("oss_version") or merged.get("core_version") or merged.get("oss_version") + core_version = console_manifest.get("core_version") or merged.get("core_version") if core_version: merged["core_version"] = core_version - merged["oss_version"] = core_version return merged @@ -1880,16 +1862,13 @@ def _write_pro_bundle_install_marker(manifest: dict[str, Any], *, bundle_sha256: marker.parent.mkdir(parents=True, exist_ok=True) release_id = manifest.get("release_id") or manifest.get("bundle_release_id") bundle_release_id = manifest.get("bundle_release_id") or manifest.get("release_id") - display_version = _console_manifest_display_version(manifest) - core_version = manifest.get("core_version") or manifest.get("oss_version") + bundle_version = _console_manifest_bundle_version(manifest) + core_version = manifest.get("core_version") payload = { "release_id": release_id, "bundle_release_id": bundle_release_id, - "bundle_version": display_version, - "display_version": display_version, - "installed_version": display_version, + "bundle_version": bundle_version, "core_version": core_version, - "oss_version": core_version, "flockspro_component_version": manifest.get("flockspro_component_version"), "build_id": manifest.get("build_id"), "bundle_sha256": bundle_sha256 or manifest.get("bundle_sha256"), @@ -1904,33 +1883,20 @@ def _write_pending_pro_bundle_install_receipt(marker_payload: dict[str, Any]) -> receipt_path.parent.mkdir(parents=True, exist_ok=True) bundle_version = str( marker_payload.get("bundle_version") - or marker_payload.get("installed_version") - or marker_payload.get("display_version") or "" ).strip() - core_version = str(marker_payload.get("core_version") or marker_payload.get("oss_version") or "").strip() + core_version = str(marker_payload.get("core_version") or "").strip() pro_component_version = str(marker_payload.get("flockspro_component_version") or "").strip() - version_info = { - "edition": "flockspro", - "version": bundle_version, - "bundle_version": bundle_version, - "core_version": core_version, - "flockspro_component_version": pro_component_version, - } receipt = { "release_id": marker_payload.get("release_id") or marker_payload.get("bundle_release_id"), "bundle_release_id": marker_payload.get("bundle_release_id") or marker_payload.get("release_id"), "license_id": _read_local_pro_license_id() or None, - "installed_version": bundle_version, "bundle_version": bundle_version, - "target_version": bundle_version, "core_version": core_version, - "oss_version": core_version, "flockspro_component_version": pro_component_version, "build_id": marker_payload.get("build_id"), "install_result": "success", "reported_at": datetime.now(timezone.utc).isoformat(), - "version_info": {key: value for key, value in version_info.items() if value}, } receipt_path.write_text(json.dumps(receipt, ensure_ascii=True, sort_keys=True), encoding="utf-8") try: @@ -2591,20 +2557,12 @@ def _read_pro_bundle_install_marker() -> dict[str, Any]: def _read_pro_bundle_installed_bundle_version() -> str: payload = _read_pro_bundle_install_marker() - for key in ("bundle_version", "installed_version", "display_version"): - version = str(payload.get(key) or "").strip() - if version: - return version - return "" + return str(payload.get("bundle_version") or "").strip() def _read_pro_bundle_installed_core_version() -> str: payload = _read_pro_bundle_install_marker() - for key in ("core_version", "oss_version"): - version = str(payload.get(key) or "").strip() - if version: - return version - return "" + return str(payload.get("core_version") or "").strip() def _read_pro_bundle_installed_component_version() -> str: @@ -3127,13 +3085,13 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: yield UpdateProgress(stage="error", message=msg, success=False) return if profile.sources == ["console-manifest"]: - skip_core_replace = _is_pro_bundle_oss_older_than_local(pro_bundle_manifest, current_version) + skip_core_replace = _is_pro_bundle_core_older_than_local(pro_bundle_manifest, current_version) if skip_core_replace: - bundle_oss_version = _pro_bundle_oss_version(pro_bundle_manifest) + bundle_core_version = _pro_bundle_core_version_for_compare(pro_bundle_manifest) pro_bundle_manifest = _effective_pro_bundle_manifest(pro_bundle_manifest, current_version) log.info( "updater.pro_bundle.keep_local_core", - {"local_version": current_version, "bundle_oss_version": bundle_oss_version}, + {"local_version": current_version, "bundle_core_version": bundle_core_version}, ) else: effective_update_version = latest_tag diff --git a/tests/console/test_console_login_heartbeat.py b/tests/console/test_console_login_heartbeat.py index bd143536f..0502ad42c 100644 --- a/tests/console/test_console_login_heartbeat.py +++ b/tests/console/test_console_login_heartbeat.py @@ -5,7 +5,28 @@ from flocks.console.login import ConsoleLoginService -def test_heartbeat_payload_includes_runtime_version_info(tmp_path, monkeypatch): +def test_heartbeat_payload_reports_oss_for_core_only_install(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setenv("FLOCKS_EDITION", "flockspro") + monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3.3")) + + payload = ConsoleLoginService.heartbeat_payload( + { + "console_session_token": "cs_heartbeat", + "fingerprint": "fp_heartbeat", + "install_id": "inst_heartbeat", + }, + ) + + assert payload["edition"] == "oss" + assert payload["core_version"] == "2026.7.3.3" + assert "version" not in payload + assert "bundle_version" not in payload + assert "flockspro_component_version" not in payload + assert "version_info" not in payload + + +def test_heartbeat_payload_includes_pro_runtime_versions(tmp_path, monkeypatch): monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) monkeypatch.setattr(ConsoleLoginService, "_runtime_version", staticmethod(lambda: "2026.7.3")) @@ -37,17 +58,11 @@ def test_heartbeat_payload_includes_runtime_version_info(tmp_path, monkeypatch): assert payload["status"] == "poc" assert payload["license_id"] == "lic_heartbeat" assert payload["edition"] == "flockspro" - assert payload["version"] == "v2026.7.3" assert payload["bundle_version"] == "v2026.7.3" assert payload["core_version"] == "v2026.7.3" assert payload["flockspro_component_version"] == "2026.7.3.1" - assert payload["version_info"] == { - "edition": "flockspro", - "version": "v2026.7.3", - "bundle_version": "v2026.7.3", - "core_version": "v2026.7.3", - "flockspro_component_version": "2026.7.3.1", - } + assert "version" not in payload + assert "version_info" not in payload def test_send_heartbeat_uses_local_pro_license_and_applies_response(tmp_path, monkeypatch): @@ -127,7 +142,11 @@ async def post(self, url, json=None, headers=None): payload = captured["json"] assert payload["status"] == "poc" assert payload["license_id"] == "lic_core" - assert payload["version_info"]["bundle_version"] == "v2026.7.3" + assert payload["bundle_version"] == "v2026.7.3" + assert payload["core_version"] == "v2026.7.3" + assert payload["flockspro_component_version"] == "2026.7.3.1" + assert "version" not in payload + assert "version_info" not in payload updated = json.loads(license_path.read_text(encoding="utf-8")) assert updated["patches"] == ["patch_token_1"] diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py index 5012946c6..6a638d3bc 100644 --- a/tests/server/routes/test_console_upgrade_routes.py +++ b/tests/server/routes/test_console_upgrade_routes.py @@ -248,7 +248,7 @@ async def test_pro_package_status_reports_installed_marker( console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "pro-v2026-05-13-3", + "bundle_version": "pro-v2026-05-13-3", "flockspro_component_version": "1.2.3", "build_id": "build_1", "installed_at": "2026-05-15T12:00:00+00:00", @@ -276,7 +276,7 @@ async def test_pro_package_status_treats_install_marker_as_installed( console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "pro-v2026.6.23", + "bundle_version": "pro-v2026.6.23", "flockspro_component_version": "2026.6.23", "installed_at": "2026-06-29T04:00:00+00:00", }, @@ -812,7 +812,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str console_routes, "_read_pro_bundle_install_marker", lambda: { - "installed_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", }, ) @@ -856,7 +856,7 @@ async def test_restarting_request_reports_receipt_after_service_restart( "approved_bundle_release_id": "rel_restart", "latest_pro_bundle": { "release_id": "rel_restart", - "display_version": "v2026.6.24", + "bundle_version": "v2026.6.24", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.24", "build_id": "job_restart", @@ -888,7 +888,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str lambda: { "release_id": "rel_restart", "bundle_release_id": "rel_restart", - "installed_version": "v2026.6.24", + "bundle_version": "v2026.6.24", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.24", "build_id": "job_restart", @@ -901,7 +901,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str payload = resp.json() assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" - assert payload["details"]["auto_install_version"] == "v2026.6.24" + assert payload["details"]["auto_install_bundle_version"] == "v2026.6.24" assert reported == [("success", None)] @@ -1021,7 +1021,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"} if installed else {}, + lambda: {"bundle_version": "v2026.5.9"} if installed else {}, ) resp = await client.post(f"/api/console/upgrade-requests/{request_id}/start") @@ -1031,7 +1031,7 @@ async def _fake_report(record: dict, *, install_result: str, error_message: str stored = await Storage.get(f"console:upgrade_request:{request_id}") assert stored["status"] == "activated" assert stored["details"]["auto_install_result"] == "done" - assert stored["details"]["auto_install_version"] == "v2026.5.9" + assert stored["details"]["auto_install_bundle_version"] == "v2026.5.9" async def test_start_revoked_request_does_not_reinstall( @@ -1086,7 +1086,7 @@ async def _noop(_record: dict, **_kwargs): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"}, + lambda: {"bundle_version": "v2026.5.9"}, ) record = { @@ -1101,7 +1101,7 @@ async def _noop(_record: dict, **_kwargs): payload = await console_routes._maybe_auto_activate_upgrade(record) assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "already_latest" - assert payload["details"]["auto_install_version"] == "v2026.5.9" + assert payload["details"]["auto_install_bundle_version"] == "v2026.5.9" assert reported == [("success", None)] @@ -1115,7 +1115,7 @@ async def test_auto_activate_reinstalls_when_existing_pro_marker_is_not_target_b "payload": { "release_id": "rel_20260601", "bundle_release_id": "rel_20260601", - "installed_version": "v2026.6.1", + "bundle_version": "v2026.6.1", "flockspro_component_version": "v2026.6.1", "build_id": "job_20260601", } @@ -1128,7 +1128,7 @@ async def _fake_perform_pro_bundle_install(*args, **kwargs): marker_state["payload"] = { "release_id": "rel_20260605", "bundle_release_id": "rel_20260605", - "installed_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_20260605", } @@ -1157,7 +1157,7 @@ async def _noop(_record: dict, **_kwargs): "approved_bundle_release_id": "rel_20260605", "latest_pro_bundle": { "release_id": "rel_20260605", - "display_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_20260605", }, @@ -1171,7 +1171,7 @@ async def _noop(_record: dict, **_kwargs): assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" assert payload["details"]["auto_install_release_id"] == "rel_20260605" - assert payload["details"]["auto_install_version"] == "v2026.6.5" + assert payload["details"]["auto_install_bundle_version"] == "v2026.6.5" assert reported == [("success", None)] @@ -1197,7 +1197,7 @@ async def _noop(_record: dict, **_kwargs): "_get_pro_capability_status", lambda: {"pro_enabled": False, "active": False, "license_status": "expired", "inactive_reason": "expired"}, ) - monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"installed_version": "v2026.5.9"}) + monkeypatch.setattr(console_routes, "_read_pro_bundle_install_marker", lambda: {"bundle_version": "v2026.5.9"}) record = { "request_id": "req_auto_inactive", @@ -1249,7 +1249,7 @@ async def _noop(_record: dict, **_kwargs): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"} if installed else {}, + lambda: {"bundle_version": "v2026.5.9"} if installed else {}, ) record = { @@ -1264,7 +1264,7 @@ async def _noop(_record: dict, **_kwargs): payload = await console_routes._maybe_auto_activate_upgrade(record) assert payload["status"] == "activated" assert payload["details"]["auto_install_result"] == "done" - assert payload["details"]["auto_install_version"] == "v2026.5.9" + assert payload["details"]["auto_install_bundle_version"] == "v2026.5.9" async def test_report_pro_bundle_installation_uses_license_id( @@ -1297,7 +1297,7 @@ async def post(self, url, json=None, headers=None): monkeypatch.setattr( console_routes, "_read_pro_bundle_install_marker", - lambda: {"installed_version": "v2026.5.9"}, + lambda: {"bundle_version": "v2026.5.9"}, ) record = { @@ -1310,7 +1310,7 @@ async def post(self, url, json=None, headers=None): "approved_bundle_release_id": "rel_receipt", "latest_pro_bundle": { "release_id": "rel_receipt", - "display_version": "v2026.6.5", + "bundle_version": "v2026.6.5", "core_version": "v2026.6.1", "flockspro_component_version": "v2026.6.5", "build_id": "job_receipt", @@ -1325,7 +1325,7 @@ async def post(self, url, json=None, headers=None): assert posted_payloads[0]["release_id"] == "rel_receipt" assert posted_payloads[0]["bundle_release_id"] == "rel_receipt" assert posted_payloads[0]["core_version"] == "v2026.6.1" - assert posted_payloads[0]["oss_version"] == "v2026.6.1" + assert "oss_version" not in posted_payloads[0] assert posted_payloads[0]["build_id"] == "job_receipt" @@ -1361,7 +1361,7 @@ async def post(self, url, json=None, headers=None): lambda: { "release_id": "rel_old", "bundle_release_id": "rel_old", - "installed_version": "v2026.6.1", + "bundle_version": "v2026.6.1", "flockspro_component_version": "v2026.6.1", "build_id": "job_old", }, @@ -1377,8 +1377,8 @@ async def post(self, url, json=None, headers=None): "approved_bundle_release_id": "rel_new", "latest_pro_bundle": { "release_id": "rel_new", - "display_version": "v2026.6.5", - "oss_version": "v2026.6.5", + "bundle_version": "v2026.6.5", + "core_version": "v2026.6.5", "flockspro_component_version": "v2026.6.5", "build_id": "job_new", }, @@ -1393,6 +1393,6 @@ async def post(self, url, json=None, headers=None): assert posted_payloads[0]["release_id"] == "rel_new" assert posted_payloads[0]["bundle_release_id"] == "rel_new" - assert posted_payloads[0]["installed_version"] == "v2026.6.5" + assert posted_payloads[0]["bundle_version"] == "v2026.6.5" assert posted_payloads[0]["build_id"] == "job_new" assert posted_payloads[0]["install_result"] == "failed" diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py index 90f8e7a78..2389f618c 100644 --- a/tests/updater/test_updater_console_manifest_bundle.py +++ b/tests/updater/test_updater_console_manifest_bundle.py @@ -26,11 +26,11 @@ def raise_for_status(self) -> None: def json(self) -> dict: return { - "display_version": "v2026.5.10", + "bundle_version": "v2026.5.10", "compare_version": "2026.5.10", "bundle_url": "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", "bundle_sha256": "abc123", - "oss_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "release_notes": "bundle release", } @@ -75,7 +75,8 @@ async def test_check_update_uses_pro_marker_bundle_version_and_component_metadat marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", @@ -93,7 +94,8 @@ async def _fake_manifest_info(): bundle_sha256=None, bundle_format="zip", manifest={ - "display_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23", }, ) @@ -126,7 +128,8 @@ async def test_check_update_force_console_manifest_uses_bundle_versions(monkeypa marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", @@ -144,7 +147,7 @@ async def _fake_manifest_info(): bundle_sha256="abc123", bundle_format="zip", manifest={ - "display_version": "v2026.5.24", + "bundle_version": "v2026.5.24", "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-24", }, @@ -180,7 +183,8 @@ async def test_check_update_force_console_manifest_detects_component_only_update marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.6.18", + "bundle_version": "v2026.6.18", + "core_version": "v2026.6.18", "flockspro_component_version": "v2026.6.1" }""", encoding="utf-8", @@ -198,8 +202,8 @@ async def _fake_manifest_info(): bundle_sha256="def456", bundle_format="zip", manifest={ - "display_version": "v2026.6.18", - "oss_version": "v2026.6.18", + "bundle_version": "v2026.6.18", + "core_version": "v2026.6.18", "flockspro_component_version": "v2026.6.2", }, ) @@ -227,7 +231,7 @@ async def test_check_update_force_console_manifest_reports_stale_product_marker_ marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.6.22", + "bundle_version": "v2026.6.22", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23" }""", @@ -246,7 +250,7 @@ async def _fake_manifest_info(): bundle_sha256="ghi789", bundle_format="zip", manifest={ - "display_version": "v2026.6.23", + "bundle_version": "v2026.6.23", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", }, @@ -278,33 +282,34 @@ def test_console_manifest_release_identity_writes_product_and_core_versions( monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) merged = updater._merge_console_manifest_release_identity( { - "display_version": "v2026.6.21", + "bundle_version": "v2026.6.21", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", }, { "release_id": "rel_623", - "display_version": "v2026.6.23", + "bundle_version": "v2026.6.23", "core_version": "v2026.6.21", "flockspro_component_version": "v2026.6.23", "build_id": "job_623", }, ) - assert merged["display_version"] == "v2026.6.23" + assert merged["bundle_version"] == "v2026.6.23" assert merged["core_version"] == "v2026.6.21" updater._write_pro_bundle_install_marker(merged, bundle_sha256="sha623") marker = json.loads((tmp_path / "run" / "pro-bundle-installed.json").read_text(encoding="utf-8")) - assert marker["installed_version"] == "v2026.6.23" + assert marker["bundle_version"] == "v2026.6.23" assert marker["core_version"] == "v2026.6.21" - assert marker["oss_version"] == "v2026.6.21" assert marker["flockspro_component_version"] == "v2026.6.23" assert marker["build_id"] == "job_623" pending = json.loads((tmp_path / "run" / "pro-bundle-install-receipt-pending.json").read_text(encoding="utf-8")) assert pending["install_result"] == "success" assert pending["bundle_version"] == "v2026.6.23" - assert pending["version_info"]["core_version"] == "v2026.6.21" + assert pending["core_version"] == "v2026.6.21" + assert pending["flockspro_component_version"] == "v2026.6.23" + assert "version_info" not in pending @pytest.mark.asyncio @@ -369,7 +374,7 @@ def raise_for_status(self) -> None: def json(self) -> dict: return { - "display_version": "v2026.5.10", + "bundle_version": "v2026.5.10", "bundle_url": "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", "frozen": True, } @@ -588,8 +593,8 @@ async def test_perform_pro_bundle_install_replaces_core_and_installs_wheel( wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "flockspro_wheel": "wheels/flockspro-0.1.0-py3-none-any.whl", "build_id": "job_test" @@ -639,12 +644,12 @@ async def _fake_run_async(cmd, **_kwargs): marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json" assert marker.is_file() marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8")) - assert marker_payload["display_version"] == "v2026.5.10" - assert marker_payload["oss_version"] == "v2026.5.10" + assert marker_payload["bundle_version"] == "v2026.5.10" + assert marker_payload["core_version"] == "v2026.5.10" @pytest.mark.asyncio -async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_oss_is_older( +async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_core_is_older( monkeypatch: pytest.MonkeyPatch, tmp_path, ) -> None: @@ -659,8 +664,8 @@ async def test_perform_pro_bundle_install_keeps_newer_local_core_when_bundle_oss wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.6.13", - "oss_version": "v2026.6.13", + "bundle_version": "v2026.6.13", + "core_version": "v2026.6.13", "flockspro_component_version": "v2026.6.2", "flockspro_wheel": "wheels/flockspro-0.2.0-py3-none-any.whl", "build_id": "job_new_pro_old_core" @@ -694,8 +699,8 @@ async def _fake_manifest_info(): bundle_format="zip", manifest={ "release_id": "rel_new_pro_old_core", - "display_version": "v2026.6.13", - "oss_version": "v2026.6.13", + "bundle_version": "v2026.6.13", + "core_version": "v2026.6.13", "flockspro_component_version": "v2026.6.2", "build_id": "job_new_pro_old_core", }, @@ -729,10 +734,8 @@ async def _fake_run_async(cmd, **_kwargs): marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json" marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8")) assert marker_payload["release_id"] == "rel_new_pro_old_core" - assert marker_payload["display_version"] == "v2026.6.13" - assert marker_payload["installed_version"] == "v2026.6.13" + assert marker_payload["bundle_version"] == "v2026.6.13" assert marker_payload["core_version"] == "v2026.6.18" - assert marker_payload["oss_version"] == "v2026.6.18" assert marker_payload["flockspro_component_version"] == "v2026.6.2" @@ -751,8 +754,8 @@ async def test_perform_pro_bundle_install_schedules_restart_before_stream_can_cl wheel.write_bytes(b"fake-wheel") (bundle_root / "manifest.json").write_text( """{ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "flockspro_wheel": "wheels/flockspro-0.1.0-py3-none-any.whl", "build_id": "job_test" @@ -803,8 +806,8 @@ async def _async_manifest_info(bundle): bundle_sha256=None, bundle_format="zip", manifest={ - "display_version": "v2026.5.10", - "oss_version": "v2026.5.10", + "bundle_version": "v2026.5.10", + "core_version": "v2026.5.10", "flockspro_component_version": "pro-v2026-5-10", "build_id": "job_test", }, diff --git a/tests/updater/test_updater_edition_sources.py b/tests/updater/test_updater_edition_sources.py index 2798ed541..a58b33ac9 100644 --- a/tests/updater/test_updater_edition_sources.py +++ b/tests/updater/test_updater_edition_sources.py @@ -10,7 +10,8 @@ async def test_installed_pro_bundle_marker_without_active_license_keeps_oss_sour marker.parent.mkdir(parents=True) marker.write_text( """{ - "installed_version": "v2026.5.23", + "bundle_version": "v2026.5.23", + "core_version": "v2026.5.23", "flockspro_component_version": "pro-v2026-05-23" }""", encoding="utf-8", From 7072da6257355ec5730bb0e7a32e5a3fdab21fd0 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 3 Jul 2026 19:28:59 +0800 Subject: [PATCH 09/10] wip --- flocks/console/login.py | 31 +++++++-- flocks/updater/restart_handoff.py | 17 +++++ pyproject.toml | 2 +- tests/console/test_console_login_heartbeat.py | 69 +++++++++++++++++++ tests/updater/test_restart_handoff.py | 34 +++++++++ 5 files changed, 147 insertions(+), 6 deletions(-) diff --git a/flocks/console/login.py b/flocks/console/login.py index 7bfeaf6d5..0fb2d179a 100644 --- a/flocks/console/login.py +++ b/flocks/console/login.py @@ -474,21 +474,40 @@ async def send_heartbeat(cls) -> dict[str, Any]: report_install_receipt=True, ) + @classmethod + async def report_pending_pro_bundle_install_receipt(cls) -> bool: + try: + session = await cls._require_session() + except Exception: + session = read_shared_console_session() + if not session: + return False + async with httpx.AsyncClient(timeout=10) as client: + return await cls._report_pending_pro_bundle_install_receipt(client=client, session=session) + + @classmethod + def _console_base_url_for_session(cls, session: dict[str, Any]) -> str: + console_base = cls.console_base_url() or str(session.get("console_base_url") or "").strip().rstrip("/") + if console_base: + return console_base + shared_session = read_shared_console_session() + return str((shared_session or {}).get("console_base_url") or "").strip().rstrip("/") + @classmethod async def _report_pending_pro_bundle_install_receipt( cls, *, client: httpx.AsyncClient, session: dict[str, Any], - ) -> None: - console_base = cls.console_base_url() + ) -> bool: + console_base = cls._console_base_url_for_session(session) token = str(session.get("console_session_token") or "").strip() if not console_base or not token: - return + return False path = _pending_pro_bundle_install_receipt_path() payload = _read_json_file(path) if not payload: - return + return False payload = { **payload, "fingerprint": session.get("fingerprint"), @@ -503,8 +522,10 @@ async def _report_pending_pro_bundle_install_receipt( ) if resp.status_code in {200, 201, 202}: path.unlink(missing_ok=True) + return True except Exception: - return + return False + return False @classmethod async def sync_node_profile(cls, *, force: bool = False, source: str = "scheduled") -> dict[str, Any]: diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index f6350b400..dcfd1505c 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -161,6 +161,22 @@ def _run_upgrade_tasks(args: argparse.Namespace) -> str | None: ) +def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None: + if not args.pro_bundle_manifest_path: + return + try: + from flocks.console.login import ConsoleLoginService + + reported = asyncio.run(ConsoleLoginService.report_pending_pro_bundle_install_receipt()) + except Exception as exc: + _record_handoff_log(f"install_receipt_report_failed error={exc}") + return + if reported: + _record_handoff_log("install_receipt_reported") + else: + _record_handoff_log("install_receipt_report_skipped") + + def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None: from flocks.updater import updater @@ -214,6 +230,7 @@ def run(argv: Sequence[str] | None = None) -> int: _rollback_failed_upgrade(args, task_error) _cleanup_dir(args.cleanup_dir) return 1 + _report_pending_pro_bundle_install_receipt(args) try: process = subprocess.Popen( diff --git a/pyproject.toml b/pyproject.toml index b3bbacd8c..a7f14f282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flocks" -version = "v2026.7.3.3" +version = "v2026.7.3.5" description = "AI-Native SecOps platform with multi-agent collaboration" authors = [ {name = "Flocks Team", email = "team@example.com"} diff --git a/tests/console/test_console_login_heartbeat.py b/tests/console/test_console_login_heartbeat.py index 0502ad42c..5103b58af 100644 --- a/tests/console/test_console_login_heartbeat.py +++ b/tests/console/test_console_login_heartbeat.py @@ -153,3 +153,72 @@ async def post(self, url, json=None, headers=None): assert updated["last_sync_at"] revocation = json.loads((tmp_path / "flockspro" / "revocation.json").read_text(encoding="utf-8")) assert revocation == {"revoked_license_ids": ["lic_revoked"]} + + +def test_report_pending_pro_bundle_install_receipt_posts_and_deletes(tmp_path, monkeypatch): + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.delenv("FLOCKS_CONSOLE_BASE_URL", raising=False) + + license_path = tmp_path / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True) + license_path.write_text(json.dumps({"license_id": "lic_pending"}), encoding="utf-8") + pending_path = tmp_path / "run" / "pro-bundle-install-receipt-pending.json" + pending_path.parent.mkdir(parents=True) + pending_path.write_text( + json.dumps( + { + "release_id": "rel_pending", + "bundle_release_id": "rel_pending", + "bundle_version": "2026.7.3.5", + "core_version": "2026.7.3.5", + "flockspro_component_version": "2026.7.3.3", + "build_id": "job_pending", + "install_result": "success", + } + ), + encoding="utf-8", + ) + + async def _require_session(cls): + return { + "console_session_token": "cs_pending", + "fingerprint": "fp_pending", + "install_id": "inst_pending", + "console_base_url": "http://127.0.0.1:18001", + } + + captured: dict[str, object] = {} + + class _Response: + status_code = 200 + + class _Client: + def __init__(self, *_args, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return _Response() + + monkeypatch.setattr(ConsoleLoginService, "_require_session", classmethod(_require_session)) + monkeypatch.setattr(login_mod.httpx, "AsyncClient", _Client) + + reported = asyncio.run(ConsoleLoginService.report_pending_pro_bundle_install_receipt()) + + assert reported is True + assert not pending_path.exists() + assert captured["url"] == "http://127.0.0.1:18001/v1/pro-bundles/installations" + assert captured["headers"] == {"Authorization": "Bearer cs_pending"} + payload = captured["json"] + assert payload["fingerprint"] == "fp_pending" + assert payload["install_id"] == "inst_pending" + assert payload["license_id"] == "lic_pending" + assert payload["bundle_version"] == "2026.7.3.5" diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index 6f9ceaab2..b9e5e1e5d 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -77,6 +77,40 @@ def test_run_waits_for_parent_and_backend_port_before_spawning( ] +def test_run_reports_pending_install_receipt_after_pro_bundle_tasks( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "serve"] + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + args = _handoff_args(tmp_path, restart_argv) + separator_index = args.index("--") + args[separator_index:separator_index] = ["--pro-bundle-manifest-path", str(manifest)] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) + monkeypatch.setattr( + restart_handoff, + "_report_pending_pro_bundle_install_receipt", + lambda args: events.append("receipt"), + ) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}") or SimpleNamespace(pid=4321), + ) + monkeypatch.setattr(restart_handoff, "_record_backend_runtime_if_direct_serve", lambda *_args, **_kwargs: None) + + code = restart_handoff.run(args) + + assert code == 0 + assert events[1:4] == ["tasks", "receipt", f"spawn:{restart_argv}"] + + def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] From 77c29c71dbab67f58df63669d9bd5830d6e4be5b Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 3 Jul 2026 20:26:15 +0800 Subject: [PATCH 10/10] fix: display Flocks Pro bundle version --- webui/src/api/consoleUpgrade.ts | 5 ++ webui/src/components/layout/Layout.tsx | 6 ++- .../src/pages/FlocksproUpgrade/index.test.tsx | 48 ++++++++++++++++++- webui/src/pages/FlocksproUpgrade/index.tsx | 42 ++++++++++++---- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/webui/src/api/consoleUpgrade.ts b/webui/src/api/consoleUpgrade.ts index e211efcb1..d49d8d2a9 100644 --- a/webui/src/api/consoleUpgrade.ts +++ b/webui/src/api/consoleUpgrade.ts @@ -37,8 +37,11 @@ export interface UpgradeRequestDetails { notes?: string | null; auto_install_target?: string; auto_install_version?: string; + auto_install_bundle_version?: string; auto_install_pro_version?: string; + auto_install_pro_component_version?: string; flockspro_component_version?: string; + bundle_version_update_to?: string; auto_install_result?: string; auto_install_completed_at?: string; license_refreshed_at?: string; @@ -66,7 +69,9 @@ export interface ProPackageStatus { installed: boolean; runtime_importable?: boolean | null; install_marker_present?: boolean | null; + bundle_version?: string | null; installed_version?: string | null; + core_version?: string | null; flockspro_component_version?: string | null; build_id?: string | null; installed_at?: string | null; diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index 26a43b5ed..854885ae8 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -282,7 +282,11 @@ export default function Layout() { const active = licenseStatus?.pro_enabled === true || packageStatus?.pro_enabled === true; setIsFlocksproActive(active); const version = active - ? formatProVersion(packageStatus?.installed_version || packageStatus?.flockspro_component_version) + ? formatProVersion( + packageStatus?.bundle_version || + packageStatus?.installed_version || + packageStatus?.flockspro_component_version, + ) : null; setFlocksproVersion(version); }) diff --git a/webui/src/pages/FlocksproUpgrade/index.test.tsx b/webui/src/pages/FlocksproUpgrade/index.test.tsx index 367a01ca8..200ccdb4b 100644 --- a/webui/src/pages/FlocksproUpgrade/index.test.tsx +++ b/webui/src/pages/FlocksproUpgrade/index.test.tsx @@ -38,7 +38,15 @@ vi.mock('@/api/consoleUpgrade', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, options?: Record) => options?.defaultValue || key, + t: (key: string, options?: Record) => { + if (options?.defaultValue) { + return options.defaultValue; + } + if (key === 'upgrade.installedTitle') { + return `Flocks Pro ${options?.version || ''}`; + } + return key; + }, i18n: { language: 'zh-CN' }, }), })); @@ -100,4 +108,42 @@ describe('FlocksproUpgradePage', () => { await waitFor(() => expect(consoleUpgradeApi.getProPackageStatus).toHaveBeenCalled()); expect(await screen.findByRole('button', { name: 'upgrade.startUpgrade' })).toBeInTheDocument(); }); + + it('shows the installed bundle version instead of the Pro component version', async () => { + consoleUpgradeApi.listRequests.mockResolvedValue([ + { + request_id: 'req-1', + status: 'activated', + license_id: 'lic-1', + license_status: 'active', + details: { + license_id: 'lic-1', + license_status: 'active', + auto_install_bundle_version: 'v2026.7.3.3', + flockspro_component_version: 'pro-v2026.7.3.3', + }, + created_at: '2026-07-03T00:00:00Z', + updated_at: '2026-07-03T00:00:00Z', + }, + ]); + consoleUpgradeApi.getProPackageStatus.mockResolvedValue({ + installed: true, + runtime_importable: true, + install_marker_present: true, + bundle_version: 'v2026.7.3.3', + flockspro_component_version: 'pro-v2026.7.3.3', + pro_enabled: true, + license_status: 'active', + }); + + render( + + + , + ); + + expect(await screen.findByText('Flocks Pro v2026.7.3.3')).toBeInTheDocument(); + expect(await screen.findByText('v2026.7.3.3')).toBeInTheDocument(); + expect(screen.queryByText('pro-v2026.7.3.3')).not.toBeInTheDocument(); + }); }); diff --git a/webui/src/pages/FlocksproUpgrade/index.tsx b/webui/src/pages/FlocksproUpgrade/index.tsx index 0097a2a7d..3bc313a0d 100644 --- a/webui/src/pages/FlocksproUpgrade/index.tsx +++ b/webui/src/pages/FlocksproUpgrade/index.tsx @@ -41,6 +41,7 @@ interface FlocksproLicenseStatus { max_members?: number | null; fingerprint?: string | null; install_id?: string | null; + bundle_version?: string | null; [key: string]: string | number | boolean | null | undefined; } @@ -127,6 +128,19 @@ function formatProVersion(version?: string | null): string { return normalized ? `pro-v${normalized}` : 'pro-v...'; } +function firstVersionValue(...values: Array): string | null { + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + return null; +} + function formatDateTimeValue(value?: string | number | null): string { if (value === null || value === undefined || value === '') { return '-'; @@ -403,16 +417,6 @@ export default function FlocksproUpgradePage() { [requests], ); - const proComponentVersion = - latestActivatedRequest?.details?.auto_install_pro_version || - latestActivatedRequest?.details?.flockspro_component_version; - const proVersion = formatProVersion( - proComponentVersion || - proPackageStatus?.flockspro_component_version || - proPackageStatus?.installed_version || - latestActivatedRequest?.details?.auto_install_version || - latestActivatedRequest?.details?.auto_install_target, - ); const isProRuntimeActive = licenseStatus?.pro_enabled === true || licenseStatus?.active === true || @@ -435,6 +439,24 @@ export default function FlocksproUpgradePage() { const currentDisplayLicenseRequest = currentDisplayLicenseId ? accountScopedRequests.find((item) => requestLicenseId(item) === currentDisplayLicenseId) : null; + const bundleVersion = firstVersionValue( + proPackageStatus?.bundle_version, + licenseStatus?.bundle_version, + currentDisplayLicenseRequest?.details?.auto_install_bundle_version, + latestActivatedRequest?.details?.auto_install_bundle_version, + currentDisplayLicenseRequest?.details?.bundle_version_update_to, + latestActivatedRequest?.details?.bundle_version_update_to, + ); + const proComponentVersion = firstVersionValue( + latestActivatedRequest?.details?.auto_install_pro_component_version, + latestActivatedRequest?.details?.auto_install_pro_version, + latestActivatedRequest?.details?.flockspro_component_version, + proPackageStatus?.flockspro_component_version, + proPackageStatus?.installed_version, + latestActivatedRequest?.details?.auto_install_version, + latestActivatedRequest?.details?.auto_install_target, + ); + const proVersion = bundleVersion || formatProVersion(proComponentVersion); const showCurrentLicenseCard = Boolean(currentDisplayLicenseId); const displayedLicenseStatus = (preferRequestLicense ? requestLicenseStatus(currentIssuedRequest as UpgradeRequestStatus)