From 13069201e6551a42c285a4041f76fbe54b62ce23 Mon Sep 17 00:00:00 2001 From: chenjie Date: Fri, 22 May 2026 13:08:16 +0800 Subject: [PATCH 01/25] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit bdee8a01224bca53583b1d28f492b9b94a6c6414 Author: chenjie Date: Thu May 21 22:21:25 2026 +0800 fix: restore console revocation sync workflow Re-enable the legacy sync-revocations API path and wire the upgrade page back to it so revoked licenses propagate again after recent auth-related regressions. Co-authored-by: Cursor commit 06dee85af690aed75f2c6a1e32c25409f691d2a1 Author: chenjie Date: Thu May 21 21:28:59 2026 +0800 chore: trim release bundle workflow config Remove obsolete release-bundle workflow lines to keep the pipeline aligned with the current packaging and release process. Co-authored-by: Cursor commit e9a450a62c90336118c9b2352009c65b0fff7ef7 Author: chenjie Date: Thu May 21 19:04:27 2026 +0800 wip: check vulns commit 54a24ec3c1bbc59ba3ce3067e1499a82f517096f Author: chenjie Date: Sat May 16 00:15:46 2026 +0800 fix: harden console license activation sync Use signed activation receipts and safer fallback state handling so Console sync works correctly with installed and uninstalled Pro components. Co-authored-by: Cursor commit 8eac9c161b25ea3aaaa1033062ee9153c3819f1f Author: chenjie Date: Fri May 15 19:59:07 2026 +0800 feat: gate Flocks Pro features by license capability Co-authored-by: Cursor commit 42c35c403dcd99dd83880781d844493e3d0f3d3d Author: chenjie Date: Fri May 15 19:08:49 2026 +0800 modify Licence display commit 42d88987492d4266cbbdf3bedf2a86413a70229d Author: chenjie Date: Fri May 15 15:58:59 2026 +0800 feat: enhance Flocks Pro license lifecycle Co-authored-by: Cursor commit 523cffea842a86ae2f19a437a6143a763fa83d5c Author: chenjie Date: Thu May 14 19:55:38 2026 +0800 wip: license display commit 795a7f1abbea518e2dbfd5e4f2047135b64d386e Author: chenjie Date: Thu May 14 19:37:26 2026 +0800 WIP:down+upgrade commit ee54a88509a48a2538b02ee956e17ce4359e4a4e Author: chenjie Date: Thu May 14 18:54:23 2026 +0800 fix: applicaiton submit commit 2039cf17b9a4f36490c5057a58632e0430840aee Author: chenjie Date: Thu May 14 14:11:01 2026 +0800 format artifact name commit 4f524ef7879d16983b1bc3407837a33147711992 Author: chenjie Date: Thu May 14 13:28:55 2026 +0800 wip: push flocsk core artifact commit e622f5ee4599e60995bddaf25b93ecd6956af44d Author: chenjie Date: Thu May 14 13:16:26 2026 +0800 WIP:console push commit b78e09d5ebe74547188c7f24857dfe3708e6fe18 Author: chenjie Date: Mon May 11 19:47:19 2026 +0800 fix Pro component loading at startup commit 1437faf63b34a9e746623cd0d2f7eca15211cb65 Author: chenjie Date: Mon May 11 18:48:02 2026 +0800 feat(console): add cloud account login flow Co-authored-by: Cursor commit 0f847453fc5455cd944f63ede1054bc98112dce6 Author: chenjie Date: Mon May 11 11:25:07 2026 +0800 feat(updater): add console-owned pro bundle upgrade Co-authored-by: Cursor commit 19e609ecb61a6c09ea5153074c6ddcf49fb46211 Author: chenjie Date: Sat May 9 21:16:24 2026 +0800 feat(flockspro): add audit log viewer Add Pro audit event capture and a gated audit log UI so admins can review and export account/session audit activity. Co-authored-by: Cursor commit ec6de7d738ae8bd16baf464497c6d8fa7f6f9265 Author: chenjie Date: Sat May 9 17:57:30 2026 +0800 feat(flockspro): add pro extension integration hooks Co-authored-by: Cursor commit 4b876804bb02aafefcb5f2f6d715f9776f15a634 Author: chenjie Date: Sat May 9 17:31:33 2026 +0800 feat(isolation): add workspace/write/session user isolation flow Unify default outputs resolution for OSS and Pro username layout, enforce stable filename writes into scoped outputs, and add local shared-session read-only UX/policy with regression tests. Co-authored-by: Cursor commit a6194ba07dbe00b14ec0d81c9c9471625bc63f11 Author: chenjie Date: Sat May 9 17:25:14 2026 +0800 fix(session): enforce owner-only write access across session actions Close permission bypasses in init/fork/revert/unrevert/summarize/shell and replace unfiltered session lookup full scans with a constant-time path to avoid performance regressions under larger session sets. Co-authored-by: Cursor commit b55e02ac4ad3f49a01054eb7b330b743c85982d8 Author: chenjie Date: Fri May 8 17:43:39 2026 +0800 fix(cloud): require binding for upgrade requests Ensure OSS upgrade requests are tied to an active cloud binding and surface upstream ACT failures as stable API errors. Co-authored-by: Cursor --- .github/workflows/release-bundle.yml | 85 ++ ...flocks-release-upgrade-technical-design.md | 653 +++++++++ flocks/audit/__init__.py | 54 + flocks/auth/__init__.py | 19 +- flocks/auth/backend.py | 100 ++ flocks/auth/local.py | 8 + flocks/auth/service.py | 80 +- flocks/cli/commands/admin.py | 22 + flocks/cli/commands/update.py | 2 + flocks/config/config.py | 11 +- flocks/console/__init__.py | 0 flocks/console/login.py | 334 +++++ flocks/console/scheduler.py | 107 ++ flocks/extensions.py | 173 +++ flocks/hooks/pipeline.py | 71 +- flocks/hooks/registry.py | 133 +- flocks/license/__init__.py | 80 + flocks/plugin/loader.py | 42 + flocks/server/app.py | 16 + flocks/server/routes/auth.py | 192 ++- flocks/server/routes/console_upgrade.py | 969 ++++++++++++ flocks/server/routes/session.py | 326 ++++- flocks/server/routes/update.py | 6 + flocks/session/policy.py | 46 +- flocks/session/prompt.py | 6 + flocks/tool/file/doc_parser.py | 5 +- flocks/tool/file/write.py | 103 +- flocks/tool/truncation.py | 2 +- flocks/updater/__init__.py | 2 + flocks/updater/models.py | 2 + flocks/updater/updater.py | 492 ++++++- flocks/workspace/manager.py | 132 +- tests/auth/test_auth_facade.py | 30 + tests/cli/test_update_command.py | 6 + tests/console/test_console_sync_scheduler.py | 91 ++ tests/hooks/test_registry.py | 89 ++ tests/server/routes/test_auth_audit_routes.py | 162 +++ .../routes/test_auth_console_login_routes.py | 376 +++++ .../routes/test_console_upgrade_routes.py | 894 ++++++++++++ tests/server/routes/test_session_routes.py | 66 +- tests/server/test_http_middleware_hooks.py | 73 + tests/session/test_runner_llm_hooks.py | 316 ++++ tests/session/test_session_policy.py | 22 +- tests/session/test_session_policy_shared.py | 31 + tests/test_extension_facades.py | 49 + tests/tool/test_write_tool.py | 60 + .../test_updater_console_manifest_bundle.py | 240 +++ tests/updater/test_updater_edition_sources.py | 48 + tests/workspace/test_workspace_manager.py | 14 + .../test_workspace_manager_layout.py | 21 + webui/src/api/auth.ts | 48 + webui/src/api/consoleUpgrade.ts | 163 +++ webui/src/api/flocksproAudit.ts | 60 + webui/src/api/flocksproUsers.ts | 86 ++ webui/src/api/session.ts | 16 + webui/src/components/layout/Layout.test.tsx | 49 + webui/src/components/layout/Layout.tsx | 130 +- webui/src/i18n.ts | 6 +- webui/src/locales/en-US/auth.json | 40 +- webui/src/locales/en-US/flockspro.json | 183 +++ webui/src/locales/en-US/nav.json | 5 +- webui/src/locales/en-US/session.json | 6 + webui/src/locales/zh-CN/auth.json | 40 +- webui/src/locales/zh-CN/flockspro.json | 183 +++ webui/src/locales/zh-CN/nav.json | 5 +- webui/src/locales/zh-CN/session.json | 6 + webui/src/pages/AdminUsers/index.tsx | 383 ++++- webui/src/pages/AuditLogs/index.tsx | 570 ++++++++ webui/src/pages/FlocksproUpgrade/Callback.tsx | 66 + webui/src/pages/FlocksproUpgrade/index.tsx | 1293 +++++++++++++++++ webui/src/pages/Session/index.tsx | 35 +- webui/src/routes/index.tsx | 20 + webui/src/types/index.ts | 2 + 73 files changed, 10106 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/release-bundle.yml create mode 100644 docs/design/flocks-release-upgrade-technical-design.md create mode 100644 flocks/audit/__init__.py create mode 100644 flocks/auth/backend.py create mode 100644 flocks/auth/local.py create mode 100644 flocks/console/__init__.py create mode 100644 flocks/console/login.py create mode 100644 flocks/console/scheduler.py create mode 100644 flocks/extensions.py create mode 100644 flocks/license/__init__.py create mode 100644 flocks/server/routes/console_upgrade.py create mode 100644 tests/auth/test_auth_facade.py create mode 100644 tests/console/test_console_sync_scheduler.py create mode 100644 tests/server/routes/test_auth_audit_routes.py create mode 100644 tests/server/routes/test_auth_console_login_routes.py create mode 100644 tests/server/routes/test_console_upgrade_routes.py create mode 100644 tests/server/test_http_middleware_hooks.py create mode 100644 tests/session/test_runner_llm_hooks.py create mode 100644 tests/session/test_session_policy_shared.py create mode 100644 tests/test_extension_facades.py create mode 100644 tests/updater/test_updater_console_manifest_bundle.py create mode 100644 tests/updater/test_updater_edition_sources.py create mode 100644 tests/workspace/test_workspace_manager_layout.py create mode 100644 webui/src/api/consoleUpgrade.ts create mode 100644 webui/src/api/flocksproAudit.ts create mode 100644 webui/src/api/flocksproUsers.ts create mode 100644 webui/src/locales/en-US/flockspro.json create mode 100644 webui/src/locales/zh-CN/flockspro.json create mode 100644 webui/src/pages/AuditLogs/index.tsx create mode 100644 webui/src/pages/FlocksproUpgrade/Callback.tsx create mode 100644 webui/src/pages/FlocksproUpgrade/index.tsx diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml new file mode 100644 index 000000000..daf3e02b1 --- /dev/null +++ b/.github/workflows/release-bundle.yml @@ -0,0 +1,85 @@ +name: Publish core artifact to Console + +on: + release: + types: [published] + +permissions: + id-token: write + contents: read + +jobs: + publish-core-artifact: + runs-on: ubuntu-latest + steps: + - name: Publish OSS core artifact + env: + CONSOLE_API_BASE: ${{ secrets.FLOCKS_CONSOLE_API_BASE }} + CONSOLE_OIDC_AUDIENCE: ${{ vars.FLOCKS_CONSOLE_OIDC_AUDIENCE }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_NOTES_EVENT: ${{ github.event.release.body }} + RELEASE_ID_EVENT: ${{ github.event.release.id }} + run: | + if [ -z "${CONSOLE_API_BASE}" ]; then + echo "FLOCKS_CONSOLE_API_BASE is required" >&2 + exit 1 + fi + AUDIENCE="${CONSOLE_OIDC_AUDIENCE:-flocks-console-ops}" + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" ]; then + echo "GitHub OIDC runtime variables are missing" >&2 + exit 1 + fi + ID_TOKEN="$(curl -fsSL -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}" | jq -r '.value')" + if [ -z "${ID_TOKEN}" ] || [ "${ID_TOKEN}" = "null" ]; then + echo "Failed to acquire GitHub OIDC token" >&2 + exit 1 + fi + OPS_TOKEN="$(jq -n --arg id_token "${ID_TOKEN}" '{id_token:$id_token}' | \ + curl -fsSL -X POST "${CONSOLE_API_BASE%/}/v1/ops/auth/github-oidc/exchange" \ + -H "content-type: application/json" \ + --data-binary @- | jq -r '.ops_token')" + if [ -z "${OPS_TOKEN}" ] || [ "${OPS_TOKEN}" = "null" ]; then + echo "Failed to exchange OIDC token for ops token" >&2 + exit 1 + fi + + OSS_VERSION="${RELEASE_TAG}" + RELEASE_NOTES="${RELEASE_NOTES_EVENT}" + ARCHIVE_URL="https://github.com/${{ github.repository }}/archive/refs/tags/${OSS_VERSION}.tar.gz" + + TMP_ARCHIVE="$(mktemp)" + curl -fSL "${ARCHIVE_URL}" -o "${TMP_ARCHIVE}" + ARCHIVE_SHA256="$(shasum -a 256 "${TMP_ARCHIVE}" | awk '{print $1}')" + ARCHIVE_FILENAME="flocks-${OSS_VERSION#v}.tar.gz" + METADATA_JSON="$(jq -nc \ + --arg archive_url "${ARCHIVE_URL}" \ + --arg archive_sha256 "${ARCHIVE_SHA256}" \ + --arg trigger_kind "release" \ + --arg ref_name "${GITHUB_REF_NAME}" \ + --arg commit_sha "${GITHUB_SHA}" \ + --arg run_id "${GITHUB_RUN_ID}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT}" \ + --arg actor "${GITHUB_ACTOR}" \ + --arg workflow_ref "${GITHUB_WORKFLOW_REF}" \ + '{ + schema_version:1, + artifact_type:"flocks-core", + trigger:$trigger_kind, + ref:$ref_name, + commit_sha:$commit_sha, + run_id:$run_id, + run_attempt:$run_attempt, + actor:$actor, + workflow_ref:$workflow_ref, + dev_only:false, + source_archive_url:$archive_url, + source_archive_sha256:$archive_sha256 + }')" + curl -fSL -X POST "${CONSOLE_API_BASE%/}/v1/ops/artifacts/flocks-core/upload" \ + -H "authorization: Bearer ${OPS_TOKEN}" \ + -F "oss_version=${OSS_VERSION}" \ + -F "release_notes=${RELEASE_NOTES}" \ + -F "published_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + -F "metadata=${METADATA_JSON}" \ + -F "file=@${TMP_ARCHIVE};filename=${ARCHIVE_FILENAME};type=application/gzip" diff --git a/docs/design/flocks-release-upgrade-technical-design.md b/docs/design/flocks-release-upgrade-technical-design.md new file mode 100644 index 000000000..af74d5146 --- /dev/null +++ b/docs/design/flocks-release-upgrade-technical-design.md @@ -0,0 +1,653 @@ +# Flocks Release And Upgrade Technical Design + +## 目标 + +本文档描述当前已实现的 Flocks OSS、Flocks Pro、Flocks Console 三仓发布、构建、升级流程。 + +核心设计原则: + +- OSS 版继续使用 GitHub Release 作为版本源。 +- Pro 版客户端只信任 Console 下发的 Pro bundle manifest。 +- Pro bundle 是 Console 侧组合发布物,由 OSS core artifact 和 latest Pro wheel artifact 合成。 +- `flockspro` 私有仓只发布企业组件 wheel,不直接构建客户可升级的 bundle。 +- 从 OSS 升级到 Pro 走 Console 审核、license 激活、Pro bundle 安装和安装回执闭环。 + +## 仓库职责 + +### `flocks` + +`flocks` 仓负责 OSS 代码发布和客户端升级执行框架。 + +当前相关实现: + +- GitHub Release 是 OSS 用户检查升级的版本源。 +- `.github/workflows/trigger-pro-bundle.yml` 在 OSS release 发布后向 Console 上报 core artifact。 +- `flocks/updater/updater.py` 负责版本检查、下载、校验、备份、替换、依赖同步、重启和回滚。 +- `flocks/server/routes/cloud_upgrade.py` 负责 OSS 到 Pro 的升级申请、审核状态同步、license 激活、自动安装和安装回执上报。 + +### `flockspro` + +`flockspro` 仓负责企业功能组件,不再负责 Pro bundle 构建。 + +当前相关实现: + +- `.github/workflows/release-wheel.yml` 在 Pro release 或手动触发时构建 wheel。 +- 构建完成后计算 wheel sha256,并向 Console 上报 Pro wheel artifact。 +- `src/flockspro/updater/manifest.py` 定义 Pro bundle manifest 客户端合约。 +- `src/flockspro/updater/source.py` 提供从 Console `/v1/manifest/latest` 读取 Pro bundle manifest 的 updater source。 + +### `flocks_console` + +`flocks_console` 是 Pro 发布升级控制面。 + +当前相关实现: + +- `src/flocks_console/app/manifest_service.py` 保存 core artifact、Pro wheel artifact、bundle build job、bundle release、安装回执。 +- `src/flocks_console/app/pro_bundle_builder.py` 在 Console 侧合成 Pro bundle。 +- `src/flocks_console/app/main.py` 提供 artifact 上报、build job、latest manifest、冻结、回滚、安装回执 API。 +- `web/app/console/upgrade-requests/page.tsx` 和 `web/app/_components/upgrade-review-modal.tsx` 展示 latest core、latest Pro wheel、latest bundle、build job、安装回执。 + +## 总体流程 + +```mermaid +flowchart TD + ossRelease["OSS GitHub Release"] --> ossUser["OSS User Upgrade From GitHub"] + ossRelease --> coreArtifact["Publish Core Artifact To Console"] + proRelease["FlocksPro Release"] --> proWheel["Publish Pro Wheel Artifact To Console"] + coreArtifact --> consoleStore["Console Artifact Store"] + proWheel --> consoleStore + consoleStore --> buildJob["Console Bundle Build Job"] + buildJob --> proBundle["Pro Bundle"] + proBundle --> latestManifest["Console Latest Manifest"] + latestManifest --> proClient["Flocks Pro Client Upgrade"] + proClient --> installReceipt["Install Receipt"] + installReceipt --> consoleStore +``` + +## 版本规则 + +### OSS 版本 + +OSS release tag 是 OSS 用户和 Pro bundle 的主版本来源,例如: + +```text +v2026.5.18 +``` + +OSS 用户看到的是: + +```text +Flocks v2026.5.18 +``` + +### Pro 组件版本 + +`flockspro` 私有包有独立组件版本,例如: + +```text +pro-v2026-5-10 +``` + +该版本只表示 Pro wheel 组件版本,不直接作为客户升级版本。 + +### Pro 对外版本 + +Pro 用户对外展示版本与 OSS release 保持一致,例如: + +```text +Flocks Pro v2026.5.18 +``` + +Console manifest 中: + +```json +{ + "display_version": "v2026.5.18", + "compare_version": "2026.5.18", + "oss_version": "v2026.5.18", + "flockspro_component_version": "pro-v2026-5-10" +} +``` + +`display_version` 用于 UI 展示,`compare_version` 用于客户端版本比较,`flockspro_component_version` 只作为详情和排障字段。 + +## OSS Release 与升级流程 + +### 发布流程 + +1. `flocks` 仓创建 GitHub Release,例如 `v2026.5.18`。 +2. OSS 用户本地 updater 按原有 Release API 检查 GitHub/Gitee/GitLab sources。 +3. `.github/workflows/trigger-pro-bundle.yml` 同时被 release published 触发。 +4. workflow 下载 OSS release archive,计算 sha256。 +5. workflow 向 Console 调用: + +```http +POST /v1/ops/artifacts/flocks-core +``` + +请求字段: + +```json +{ + "oss_version": "v2026.5.18", + "archive_url": "https://github.com/AgentFlocks/flocks/archive/refs/tags/v2026.5.18.tar.gz", + "archive_sha256": "...", + "release_notes": "...", + "source_repo": "AgentFlocks/flocks", + "github_release_id": "...", + "published_at": "2026-05-18T00:00:00Z" +} +``` + +### OSS 客户端升级 + +OSS 用户不依赖 Console。 + +客户端流程: + +1. `check_update()` 调用 GitHub/Gitee/GitLab release source。 +2. 获取 latest tag、release notes、archive URL。 +3. 比较 latest tag 与当前版本。 +4. 用户确认升级后调用 `perform_update()`。 +5. 下载 archive。 +6. 备份当前安装目录到 `~/.flocks/version/`。 +7. 解压新版本源码。 +8. 构建前端资源。 +9. 替换安装目录。 +10. 运行 `uv sync`。 +11. 写入版本 marker。 +12. 重启服务。 +13. 失败时尽量从备份回滚。 + +## FlocksPro Release 与 Pro Wheel Artifact + +### 发布流程 + +1. `flockspro` 仓创建 release,例如 `pro-v2026-5-10`。 +2. `.github/workflows/release-wheel.yml` 被 release published 触发,或手动 workflow_dispatch 触发。 +3. workflow 使用 `uv build --wheel --out-dir dist` 构建 wheel。 +4. workflow 计算 wheel sha256。 +5. workflow 拼出 wheel URL。 +6. workflow 向 Console 调用: + +```http +POST /v1/ops/artifacts/flockspro-wheel +``` + +请求字段: + +```json +{ + "pro_version": "pro-v2026-5-10", + "wheel_url": "https://cdn.agentflocks.com/flockspro/wheels/pro-v2026-5-10/flockspro-0.1.0-py3-none-any.whl", + "wheel_name": "flockspro-0.1.0-py3-none-any.whl", + "wheel_sha256": "...", + "release_notes": "...", + "source_repo": "AgentFlocks/flockspro", + "github_release_id": "...", + "published_at": "2026-05-10T00:00:00Z" +} +``` + +### 不触发 bundle + +Pro wheel artifact 上报只更新 Console 中 latest Pro 组件,不自动创建客户可升级 bundle。 + +原因: + +- Pro 组件独立迭代不应直接推动客户升级。 +- 客户可升级版本以 OSS release 版本为主版本。 +- 下一个 OSS release 会自动组合当前 latest Pro wheel。 + +## Console Artifact Store + +Console 维护以下数据: + +### `core_artifacts` + +保存 OSS release artifact: + +- `artifact_id` +- `oss_version` +- `archive_url` +- `archive_sha256` +- `release_notes` +- `source_repo` +- `github_release_id` +- `published_at` +- `is_latest` +- `metadata` + +### `pro_wheel_artifacts` + +保存 Pro wheel artifact: + +- `artifact_id` +- `pro_version` +- `wheel_url` +- `wheel_name` +- `wheel_sha256` +- `release_notes` +- `source_repo` +- `github_release_id` +- `published_at` +- `is_latest` +- `metadata` + +### `pro_bundle_build_jobs` + +保存 bundle 构建任务: + +- `job_id` +- `core_artifact_id` +- `pro_artifact_id` +- `release_id` +- `channel` +- `status` +- `reason` +- `error_message` +- `created_at` +- `updated_at` +- `metadata` + +### `pro_bundle_releases` + +保存已成功发布的 Pro bundle: + +- `release_id` +- `channel` +- `display_version` +- `compare_version` +- `oss_version` +- `flockspro_component_version` +- `bundle_url` +- `bundle_sha256` +- `build_id` +- `release_notes` +- `published_at` +- `is_latest` +- `is_frozen` +- `metadata` + +### `pro_bundle_installations` + +保存客户端安装回执: + +- `id` +- `release_id` +- `license_id` +- `passport_uid` +- `fingerprint` +- `install_id` +- `installed_version` +- `oss_version` +- `flockspro_component_version` +- `build_id` +- `install_result` +- `error_message` +- `reported_at` + +## Console Bundle Build 流程 + +### 自动触发 + +当 Console 收到 core artifact 时: + +1. 写入 `core_artifacts`,并设为 latest core。 +2. 查找 latest Pro wheel artifact。 +3. 如果存在 latest Pro wheel,创建 `pro_bundle_build_jobs`。 +4. 当前实现会同步执行 build job。 +5. 构建成功后写入 `pro_bundle_releases`,并设为 latest。 +6. 如果不存在 latest Pro wheel,只保存 core artifact,不创建 bundle。 + +### 手动触发 + +Console 提供手动构建 API: + +```http +POST /v1/ops/pro-bundles/builds +``` + +请求可指定: + +```json +{ + "core_artifact_id": "core_...", + "pro_artifact_id": "prowhl_...", + "channel": "flockspro", + "reason": "manual rebuild" +} +``` + +如果不指定 artifact id,则默认使用 latest core 和 latest Pro wheel。 + +### 构建内容 + +`src/flocks_console/app/pro_bundle_builder.py` 负责生成 bundle。 + +输入: + +- core archive URL +- core archive sha256 +- Pro wheel URL +- Pro wheel sha256 +- OSS version +- Pro component version +- channel +- build id +- release notes + +构建步骤: + +1. 下载或复制 core archive。 +2. 校验 core archive sha256。 +3. 下载或复制 Pro wheel。 +4. 校验 Pro wheel sha256。 +5. 解压 core archive。 +6. 将 core 源码放入 bundle 的 `flocks/`。 +7. 将 Pro wheel 放入 bundle 的 `wheels/`。 +8. 生成 bundle 内部 `manifest.json`。 +9. 生成 `checksums.txt`。 +10. 打包为 `flockspro-bundle-vYYYY.M.D.tar.gz`。 +11. 计算 bundle sha256。 +12. 写入本地 dev storage 或对象存储对应目录。 + +Bundle 结构: + +```text +flockspro-bundle-v2026.5.18.tar.gz +├── flocks/ +├── wheels/ +│ └── flockspro-*.whl +├── manifest.json +└── checksums.txt +``` + +内部 `manifest.json`: + +```json +{ + "schema_version": 1, + "display_version": "v2026.5.18", + "compare_version": "2026.5.18", + "channel": "flockspro", + "edition": "flockspro", + "oss_version": "v2026.5.18", + "flockspro_component_version": "pro-v2026-5-10", + "flockspro_wheel": "wheels/flockspro-0.1.0-py3-none-any.whl", + "build_id": "job_...", + "published_at": "2026-05-18T00:00:00Z", + "requires_license_status": ["trial", "test", "commercial"], + "release_notes": "..." +} +``` + +如果配置了 `FLOCKSPRO_MANIFEST_SIGNING_SECRET`,内部 manifest 会附加 `manifest_signature`。 + +## Console Manifest 下发 + +Pro 客户端检查升级时访问: + +```http +GET /v1/manifest/latest?channel=flockspro +``` + +Console 会: + +1. 校验 cloud session。 +2. 读取 `pro_bundle_releases` 中 latest release。 +3. 如果 release 已 frozen,则拒绝下发。 +4. 返回 bundle manifest。 +5. 如果配置了 `FLOCKSPRO_MANIFEST_SIGNING_SECRET`,返回 manifest_signature。 + +返回示例: + +```json +{ + "schema_version": 1, + "edition": "flockspro", + "display_version": "v2026.5.18", + "compare_version": "2026.5.18", + "channel": "flockspro", + "bundle_url": "https://cdn.agentflocks.com/flockspro/v2026.5.18/flockspro-bundle-v2026.5.18.tar.gz", + "bundle_sha256": "...", + "oss_version": "v2026.5.18", + "flockspro_component_version": "pro-v2026-5-10", + "build_id": "job_...", + "published_at": "2026-05-18T00:00:00Z", + "requires_license_status": ["trial", "test", "commercial"], + "release_notes": "..." +} +``` + +## Pro 客户端升级流程 + +### Source 锁定 + +`flocks/updater/updater.py` 中 `_resolve_sources_for_edition()` 会检测: + +- `FLOCKS_EDITION=flockspro` +- 或本地存在 cloud session + +只要进入 Pro edition,升级源强制为: + +```python +["cloud-manifest"] +``` + +Pro 用户不会回退到 GitHub/Gitee/GitLab OSS source。 + +### 检查更新 + +Pro 客户端调用 `_fetch_cloud_manifest_release()`: + +1. 读取 `FLOCKS_MANIFEST_BASE_URL`。 +2. 默认 channel 为 `flockspro`。 +3. 携带 cloud session token 调用 Console `/v1/manifest/latest`。 +4. 检查 `frozen` 和 `frozen_until`。 +5. 读取 `compare_version` 作为比较版本。 +6. 读取 `bundle_url` 作为下载 URL。 +7. 缓存 manifest,用于后续 bundle sha256 校验。 + +### 执行升级 + +`perform_update()` 对 Pro bundle 执行以下步骤: + +1. 下载 bundle。 +2. 使用 manifest 中的 `bundle_sha256` 校验下载文件。 +3. 备份当前安装目录。 +4. 解压 bundle。 +5. 识别 bundle 结构: + - 如果存在 `manifest.json` 和 `flocks/`,认为是 Pro bundle。 + - 使用 `flocks/` 作为 OSS 源码根目录。 + - 从 `manifest.json` 的 `flockspro_wheel` 或 `wheels/*.whl` 找到 Pro wheel。 +6. 预构建前端。 +7. 替换安装目录。 +8. 运行 `uv sync`。 +9. 使用 `uv pip install --python .venv/bin/python wheels/flockspro-*.whl` 安装 Pro wheel。 +10. 写入 `~/.flocks/run/pro-bundle-installed.json`,记录: + - `installed_version` + - `oss_version` + - `flockspro_component_version` + - `build_id` + - `installed_at` +11. 写入当前版本 marker。 +12. 刷新 CLI entry。 +13. 重启服务或在自动安装场景下跳过 restart。 +14. 失败时从备份回滚。 + +## 从 OSS 升级到 Pro 的流程 + +OSS 到 Pro 是一次 edition switch,不是普通 OSS release 升级。 + +### 申请阶段 + +1. OSS 用户在本地发起 Pro 升级申请。 +2. `flocks/server/routes/cloud_upgrade.py` 创建本地升级申请记录。 +3. 客户端要求已有 cloud binding session。 +4. OSS 节点向 Console 创建 upgrade request。 +5. Console 审核台展示申请信息、latest core、latest Pro wheel、latest bundle、build job、安装回执。 + +### 审核阶段 + +1. Console 运维审核申请。 +2. 审核通过后生成 activate key。 +3. 审核记录绑定当前 latest Pro bundle 的 `manifest_url`。 +4. OSS 客户端刷新申请状态。 + +### 激活与安装阶段 + +当 OSS 客户端发现申请状态为 `approved`: + +1. `_maybe_activate_pro_license()` 调用 Pro license checker 激活 license。 +2. `_maybe_refresh_pro_license()` 刷新 cloud license 状态。 +3. `_run_auto_upgrade_install()` 调用 `check_update()`。 +4. 由于已进入 Pro 流程,升级源为 Console cloud manifest。 +5. 下载并安装 latest Pro bundle。 +6. 安装成功后本地状态变为 `activated`。 + +### 回执阶段 + +安装完成后: + +1. 客户端读取 `~/.flocks/run/pro-bundle-installed.json`。 +2. 调用 Console: + +```http +POST /v1/pro-bundles/installations +``` + +请求字段: + +```json +{ + "license_id": "act_...", + "fingerprint": "...", + "install_id": "...", + "installed_version": "v2026.5.18", + "oss_version": "v2026.5.18", + "flockspro_component_version": "pro-v2026-5-10", + "build_id": "job_...", + "install_result": "success", + "reported_at": "2026-05-18T00:00:00Z" +} +``` + +失败时 `install_result` 为 `failed`,并附带 `error_message`。 + +## 运维 API + +### Artifact API + +```http +POST /v1/ops/artifacts/flocks-core +GET /v1/ops/artifacts/flocks-core +POST /v1/ops/artifacts/flockspro-wheel +GET /v1/ops/artifacts/flockspro-wheel +``` + +### Bundle Build API + +```http +POST /v1/ops/pro-bundles/builds +GET /v1/ops/pro-bundles/builds +``` + +### Bundle Release API + +```http +POST /v1/ops/pro-bundles/publish +GET /v1/ops/pro-bundles +POST /v1/ops/pro-bundles/{release_id}/freeze +POST /v1/ops/pro-bundles/{release_id}/promote +``` + +`publish` 当前保留为内部发布步骤或兼容运维入口。正常流程中,成功的 Console build job 会自动写入 `pro_bundle_releases`。 + +### Installation API + +```http +POST /v1/pro-bundles/installations +GET /v1/ops/pro-bundles/installations +``` + +## 冻结与回滚 + +### 冻结 + +如果某个 Pro bundle 有问题,运维可调用: + +```http +POST /v1/ops/pro-bundles/{release_id}/freeze +``` + +冻结后 latest manifest 不再向客户端下发该 release。 + +### 回滚 + +如果需要回滚到旧 bundle,运维可调用: + +```http +POST /v1/ops/pro-bundles/{release_id}/promote +``` + +该 release 会成为 latest,并解除 frozen 状态。 + +## 配置项 + +### GitHub Actions Secrets + +`flocks` 仓: + +- `FLOCKS_CONSOLE_API_BASE` +- `FLOCKS_CONSOLE_OPS_TOKEN` + +`flockspro` 仓: + +- `FLOCKS_CONSOLE_API_BASE` +- `FLOCKS_CONSOLE_OPS_TOKEN` +- `FLOCKSPRO_WHEEL_BASE_URL` + +### Console 环境变量 + +- `FLOCKS_CONSOLE_OPS_TOKEN`:保护 ops API。 +- `FLOCKSPRO_MANIFEST_SIGNING_SECRET`:签名 manifest。 +- `FLOCKS_CONSOLE_BUNDLE_DIR`:本地 bundle 存储目录,默认 `~/.flocks/console/bundles`。 +- `FLOCKS_CONSOLE_BUNDLE_BASE_URL`:bundle 对外访问 base URL,默认 `https://cdn.agentflocks.com/flockspro`。 + +### Pro 客户端环境变量 + +- `FLOCKS_EDITION=flockspro`:强制进入 Pro edition。 +- `FLOCKS_MANIFEST_BASE_URL`:Console manifest base URL。 +- `FLOCKS_UPDATE_CHANNEL=flockspro`:Pro bundle channel。 + +## 当前实现边界 + +当前实现已经完成主链路: + +- OSS release 上报 core artifact。 +- Pro release 上报 wheel artifact。 +- Console 保存 artifact。 +- Console 根据 latest core + latest Pro wheel 构建 bundle。 +- Console 下发 latest manifest。 +- Pro 客户端下载 bundle、校验 sha256、安装 OSS core 和 Pro wheel。 +- OSS 到 Pro 通过审核、license 激活、bundle 安装、安装回执闭环。 + +仍需部署侧保证: + +- workflow 中上报的 `archive_url`、`wheel_url` 必须能被 Console builder 下载。 +- 如果使用对象存储/CDN,需要由 CI 或发布平台先上传 artifact,再上报 Console。 +- 当前 Console builder 在 API 进程内同步执行,生产环境建议迁移为后台 worker/job runner。 +- `FLOCKS_CONSOLE_BUNDLE_BASE_URL` 需要指向真实可下载的 bundle 对外地址。 + +## 测试覆盖 + +当前相关测试: + +- `flockspro/tests/test_manifest_contract.py`:Pro manifest 合约解析与签名。 +- `flocks/tests/updater/test_updater_cloud_manifest_bundle.py`:Pro cloud manifest bundle URL、冻结逻辑。 +- `flocks/tests/updater/test_updater_edition_sources.py`:Pro edition 强制 cloud manifest。 +- `flocks/tests/server/routes/test_cloud_upgrade_routes.py`:OSS 到 Pro 升级申请、自动激活、安装触发。 +- `flocks_console/tests/test_api.py`:artifact 上报、bundle build、latest manifest、安装回执。 +- `flocks_console/tests/test_schema_migrations.py`:schema 表结构覆盖。 + diff --git a/flocks/audit/__init__.py b/flocks/audit/__init__.py new file mode 100644 index 000000000..264206ab9 --- /dev/null +++ b/flocks/audit/__init__.py @@ -0,0 +1,54 @@ +""" +Audit sink facade (OSS default: no-op sink). +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from flocks.extensions import ensure_callable_methods + + +@runtime_checkable +class AuditSink(Protocol): + @classmethod + async def emit(cls, event_type: str, payload: dict[str, Any]) -> None: ... + + +class NullAuditSink: + @classmethod + async def emit(cls, event_type: str, payload: dict[str, Any]) -> None: + return None + + +class _AuditService: + _sink: type[AuditSink] = NullAuditSink + + @classmethod + def register_sink(cls, sink: type[AuditSink]) -> None: + if sink is None: + raise ValueError("sink 不能为空") + ensure_callable_methods(sink, ("emit",), label="audit sink") + cls._sink = sink + + @classmethod + def get_sink(cls) -> type[AuditSink]: + return cls._sink + + @classmethod + async def emit(cls, event_type: str, payload: dict[str, Any]) -> None: + await cls._sink.emit(event_type=event_type, payload=payload) + + +register_sink = _AuditService.register_sink +get_sink = _AuditService.get_sink +emit_audit_event = _AuditService.emit + +__all__ = [ + "AuditSink", + "NullAuditSink", + "register_sink", + "get_sink", + "emit_audit_event", +] + diff --git a/flocks/auth/__init__.py b/flocks/auth/__init__.py index 79d0d1a46..ea9f46de9 100644 --- a/flocks/auth/__init__.py +++ b/flocks/auth/__init__.py @@ -2,7 +2,24 @@ Local account authentication package. """ +from flocks.auth.backend import AuthBackend from flocks.auth.context import AuthUser +from flocks.auth.local import LocalAuthBackend from flocks.auth.service import AuthService -__all__ = ["AuthUser", "AuthService"] + +def register_backend(backend: type[AuthBackend]) -> None: + AuthService.register_backend(backend) + + +def get_backend(): + return AuthService.get_backend() + +__all__ = [ + "AuthBackend", + "AuthUser", + "AuthService", + "LocalAuthBackend", + "register_backend", + "get_backend", +] diff --git a/flocks/auth/backend.py b/flocks/auth/backend.py new file mode 100644 index 000000000..93af8034a --- /dev/null +++ b/flocks/auth/backend.py @@ -0,0 +1,100 @@ +""" +Auth backend extension protocol. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Dict, List, Optional, Protocol, Tuple, Literal + +from flocks.auth.context import AuthUser + +if TYPE_CHECKING: + from flocks.auth.service import LocalUser + + +class AuthBackend(Protocol): + """Contract for pluggable auth backends.""" + + @classmethod + async def init(cls) -> None: ... + + @classmethod + async def has_users(cls) -> bool: ... + + @classmethod + async def get_bootstrap_status(cls) -> Dict[str, bool]: ... + + @classmethod + async def bootstrap_admin(cls, username: str, password: str) -> "LocalUser": ... + + @classmethod + async def get_user_by_id(cls, user_id: str) -> Optional["LocalUser"]: ... + + @classmethod + async def get_user_by_username(cls, username: str) -> Optional[Tuple["LocalUser", str, Optional[str]]]: ... + + @classmethod + async def list_users(cls) -> List["LocalUser"]: ... + + @classmethod + async def create_user( + cls, + *, + username: str, + password: str, + role: Literal["admin", "member"], + ) -> "LocalUser": ... + + @classmethod + async def update_user_role( + cls, + *, + target_user_id: str, + new_role: Literal["admin", "member"], + ) -> "LocalUser": ... + + @classmethod + async def delete_user(cls, *, target_user_id: str) -> None: ... + + @classmethod + async def get_user_by_session_id(cls, session_id: str) -> Optional["LocalUser"]: ... + + @classmethod + async def revoke_session(cls, session_id: str) -> None: ... + + @classmethod + async def login(cls, username: str, password: str, *, persist: bool = True) -> Tuple["LocalUser", str]: ... + + @classmethod + async def change_password( + cls, + user: AuthUser, + *, + current_password: str, + new_password: str, + ) -> None: ... + + @classmethod + async def set_password( + cls, + *, + target_user_id: str, + new_password: str, + must_reset_password: bool, + temp_password_expires_at: Optional[str] = None, + ) -> None: ... + + @classmethod + async def generate_admin_temp_password(cls, *, username: str = "admin") -> str: ... + + @classmethod + async def reassign_orphan_sessions( + cls, + admin_user_id: str, + *, + dry_run: bool = False, + ) -> Dict[str, int]: ... + + @classmethod + async def migrate_legacy_sessions_to_admin(cls, admin_user_id: str) -> None: ... + diff --git a/flocks/auth/local.py b/flocks/auth/local.py new file mode 100644 index 000000000..dbb972635 --- /dev/null +++ b/flocks/auth/local.py @@ -0,0 +1,8 @@ +""" +Default local auth backend export. +""" + +from flocks.auth.service import LocalAuthBackend + +__all__ = ["LocalAuthBackend"] + diff --git a/flocks/auth/service.py b/flocks/auth/service.py index 7a9eb6187..ba74aef53 100644 --- a/flocks/auth/service.py +++ b/flocks/auth/service.py @@ -15,6 +15,7 @@ from pydantic import BaseModel, Field from flocks.auth.context import AuthUser +from flocks.extensions import ensure_callable_methods from flocks.storage.storage import Storage from flocks.utils.id import Identifier from flocks.utils.log import Log @@ -64,8 +65,8 @@ def to_auth_user(self) -> AuthUser: ) -class AuthService: - """Single-admin account and session service.""" +class LocalAuthBackend: + """Default local account/session backend.""" _initialized: bool = False _initialized_db_path: Optional[str] = None @@ -82,6 +83,8 @@ async def init(cls) -> None: db_path = Storage.get_db_path() if cls._initialized and cls._initialized_db_path == str(db_path) and db_path.exists(): return + # Switching to a new DB path (common in tests) must clear cached state. + cls._has_users_cached = False async with Storage.connect(db_path) as db: await db.executescript( """ @@ -586,3 +589,76 @@ async def migrate_legacy_sessions_to_admin(cls, admin_user_id: str) -> None: except Exception as exc: log.warn("auth.migrate_legacy_sessions.failed", {"error": str(exc)}) raise + + +class _AuthServiceFacadeMeta(type): + """Delegate unknown class attributes to the configured backend.""" + + _MIRRORED_STATE_ATTRS = ("_initialized", "_initialized_db_path", "_has_users_cached") + + def __getattr__(cls, name: str): + backend = cls.get_backend() + return getattr(backend, name) + + def __setattr__(cls, name: str, value): + super().__setattr__(name, value) + if name in cls._MIRRORED_STATE_ATTRS and hasattr(cls, "_backend"): + backend = cls.get_backend() + if hasattr(backend, name): + setattr(backend, name, value) + + +class AuthService(metaclass=_AuthServiceFacadeMeta): + """ + Authentication facade. + + The OSS default backend is ``LocalAuthBackend``. Flocks Pro packages can + swap in a compatible backend via ``register_backend``. + """ + + _backend = LocalAuthBackend + _initialized = LocalAuthBackend._initialized + _initialized_db_path = LocalAuthBackend._initialized_db_path + _has_users_cached = LocalAuthBackend._has_users_cached + + @classmethod + def register_backend(cls, backend) -> None: + if backend is None: + raise ValueError("backend 不能为空") + ensure_callable_methods( + backend, + ( + "init", + "has_users", + "get_bootstrap_status", + "bootstrap_admin", + "get_user_by_id", + "get_user_by_username", + "list_users", + "get_user_by_session_id", + "revoke_session", + "login", + "change_password", + "set_password", + "generate_admin_temp_password", + "reassign_orphan_sessions", + "migrate_legacy_sessions_to_admin", + ), + label="auth backend", + ) + cls._backend = backend + for attr in _AuthServiceFacadeMeta._MIRRORED_STATE_ATTRS: + if hasattr(backend, attr): + setattr(backend, attr, getattr(cls, attr)) + log.info("auth.backend.registered", {"backend": getattr(backend, "__name__", str(backend))}) + + @classmethod + def reset_backend(cls) -> None: + cls._backend = LocalAuthBackend + for attr in _AuthServiceFacadeMeta._MIRRORED_STATE_ATTRS: + setattr(LocalAuthBackend, attr, getattr(cls, attr)) + log.info("auth.backend.reset", {"backend": "LocalAuthBackend"}) + + @classmethod + def get_backend(cls): + return cls._backend diff --git a/flocks/cli/commands/admin.py b/flocks/cli/commands/admin.py index 47ca485bb..b1484846d 100644 --- a/flocks/cli/commands/admin.py +++ b/flocks/cli/commands/admin.py @@ -16,6 +16,7 @@ from flocks.config.config import Config from flocks.security import get_secret_manager from flocks.server.auth import API_TOKEN_SECRET_ID +from flocks.workspace.manager import WorkspaceManager admin_app = typer.Typer(help="Admin account and security maintenance commands") console = Console() @@ -189,3 +190,24 @@ async def _run() -> str: f"(valid for {TEMP_PASSWORD_TTL_HOURS} hours, password change required on first login)[/yellow]" ) console.print(f"[bold]{temp_password}[/bold]") + + +@admin_app.command("migrate-workspace-to-user") +def migrate_workspace_to_user( + admin_user_id: str = typer.Option(..., "--admin-user-id", help="目标管理员 user_id"), + dry_run: bool = typer.Option(False, "--dry-run", help="仅预览,不实际迁移"), +): + """ + 将历史单用户 workspace 目录迁移到 users/shared 双区布局。 + """ + try: + manager = WorkspaceManager.get_instance() + result = manager.migrate_root_workspace_to_user(admin_user_id=admin_user_id, dry_run=dry_run) + except Exception as exc: + console.print(f"[red]Workspace migrate failed: {exc}[/red]") + raise typer.Exit(1) from exc + + mode = " (dry-run)" if dry_run else "" + console.print(f"[green]Workspace migration summary{mode}[/green]") + console.print(f"- moved_outputs: {result['moved_outputs']}") + console.print(f"- moved_knowledge: {result['moved_knowledge']}") diff --git a/flocks/cli/commands/update.py b/flocks/cli/commands/update.py index 37a9244fd..6e884643b 100644 --- a/flocks/cli/commands/update.py +++ b/flocks/cli/commands/update.py @@ -131,6 +131,8 @@ def _finish_active(success: bool = True) -> None: version_to_apply, zipball_url=info.zipball_url, tarball_url=info.tarball_url, + bundle_sha256=info.bundle_sha256, + bundle_format=info.bundle_format, restart=False, region=region, ): diff --git a/flocks/config/config.py b/flocks/config/config.py index 1938106d6..d40b0ad3a 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -314,6 +314,10 @@ class ToolOutputConfig(BaseModel): class EnterpriseConfig(BaseModel): """Enterprise configuration""" + + +class FlocksProConfig(BaseModel): + """Flocks Pro configuration""" url: Optional[str] = None @@ -577,7 +581,7 @@ class ConfigInfo(BaseModel): ), ) agent_logic: Optional[Literal["base", "rex"]] = Field(None, alias="agentLogic") - enterprise: Optional[EnterpriseConfig] = None + flockspro: Optional[FlocksProConfig] = None compaction: Optional[CompactionConfig] = None tool_output: Optional[ToolOutputConfig] = Field( None, @@ -626,6 +630,11 @@ class ConfigInfo(BaseModel): None, description="Self-update configuration (GitHub repo, git remote, etc.)", ) + portal_base_url: Optional[str] = Field( + None, + alias="portalBaseUrl", + description="Console portal base URL used by OSS console account login redirect.", + ) @model_validator(mode='after') def post_process(self): diff --git a/flocks/console/__init__.py b/flocks/console/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flocks/console/login.py b/flocks/console/login.py new file mode 100644 index 000000000..ad79843c7 --- /dev/null +++ b/flocks/console/login.py @@ -0,0 +1,334 @@ +"""OSS console login orchestration for local nodes.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import secrets +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import httpx + +from flocks import __version__ +from flocks.storage.storage import Storage + + +def _shared_console_session_path() -> Path: + raw = os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks")) + return Path(raw).expanduser() / "run" / "console-session.json" + + +def _write_shared_console_session(session: dict[str, Any]) -> None: + path = _shared_console_session_path() + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "console_session_token": session.get("console_session_token"), + "fingerprint": session.get("fingerprint"), + "install_id": session.get("install_id"), + "passport_uid": session.get("passport_uid"), + "expires_at": session.get("expires_at"), + "updated_at": session.get("updated_at") or _now_iso(), + "console_base_url": ConsoleLoginService.console_base_url(), + } + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def _delete_shared_console_session() -> None: + path = _shared_console_session_path() + try: + path.unlink() + except FileNotFoundError: + return + except OSError: + return + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def _parse_iso(value: str) -> datetime: + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed + + +class ConsoleLoginService: + @classmethod + async def _get_install_id(cls) -> str: + key = "console:install_id" + existing = await Storage.get(key) + if existing: + return str(existing) + install_id = str(uuid4()) + await Storage.set(key, install_id, "string") + return install_id + + @classmethod + async def get_fingerprint(cls) -> str: + install_id = await cls._get_install_id() + raw = f"{platform.node()}|{platform.machine()}|{platform.system()}|{install_id}" + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + @staticmethod + def console_base_url() -> str: + raw = os.getenv("FLOCKS_CONSOLE_BASE_URL", "").strip().rstrip("/") + if not raw: + return "" + if raw.startswith(("http://", "https://")): + return raw + return f"https://{raw}" + + @classmethod + async def start_console_login(cls, return_to: str) -> dict[str, Any]: + console_base = cls.console_base_url() + if not console_base: + raise ValueError("未配置 FLOCKS_CONSOLE_BASE_URL,无法发起云账号登录") + console_login_id = str(uuid4()) + state = secrets.token_urlsafe(24) + payload = { + "console_login_id": console_login_id, + "state": state, + "fingerprint": await cls.get_fingerprint(), + "install_id": await cls._get_install_id(), + "return_to": return_to, + "created_at": _now_iso(), + "status": "pending", + } + + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(f"{console_base}/v1/flocks/console-logins", json=payload) + resp.raise_for_status() + data = resp.json() + console_login_id = data.get("console_login_id", console_login_id) + passport_login_url = data.get("passport_login_url") + if not passport_login_url: + raise ValueError("console 未返回 passport_login_url") + payload.update({"console_login_id": console_login_id, "status": "pending_remote"}) + await Storage.set(f"console:login:{console_login_id}", payload, "json") + return {"console_login_id": console_login_id, "passport_login_url": passport_login_url} + + @classmethod + async def finish_console_login( + cls, + console_login_id: str, + state: str | None = None, + passport_uid: str | None = None, + ) -> dict[str, Any]: + console_base = cls.console_base_url() + if not console_base: + raise ValueError("未配置 FLOCKS_CONSOLE_BASE_URL,无法完成云账号登录") + pending = await Storage.get(f"console:login:{console_login_id}") + if not isinstance(pending, dict): + raise ValueError("console_login_id 不存在或已过期") + expected_state = str(pending.get("state") or "") + if expected_state and state != expected_state: + raise ValueError("console login state 校验失败") + + fingerprint = await cls.get_fingerprint() + install_id = await cls._get_install_id() + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/flocks/console-logins/{console_login_id}/exchange", + json={ + "fingerprint": fingerprint, + "install_id": install_id, + "state": state, + }, + ) + resp.raise_for_status() + data = resp.json() + token = data.get("console_session_token") + if not token: + raise ValueError("console 未返回 console_session_token") + console_passport_uid = data.get("passport_uid") + user_email = data.get("user_email") + user_display = data.get("user_display") + expires_at = data.get("expires_at") + + console_session = { + "console_login_id": console_login_id, + "console_session_token": token, + "fingerprint": fingerprint, + "install_id": install_id, + "passport_uid": console_passport_uid or passport_uid, + "user_email": user_email, + "user_display": user_display, + "expires_at": expires_at, + "updated_at": _now_iso(), + } + await Storage.set("console:session", console_session, "json") + await Storage.set(f"console:login:{console_login_id}", {**pending, "status": "exchanged"}, "json") + _write_shared_console_session(console_session) + return console_session + + @classmethod + async def get_console_session(cls) -> dict[str, Any] | None: + raw = await Storage.get("console:session") + if not isinstance(raw, dict): + return None + expires_at = str(raw.get("expires_at") or "").strip() + if expires_at: + try: + if _parse_iso(expires_at) <= datetime.now(UTC): + await Storage.delete("console:session") + _delete_shared_console_session() + return None + except ValueError: + await Storage.delete("console:session") + _delete_shared_console_session() + return None + return raw + + @classmethod + async def refresh_console_session(cls) -> dict[str, Any]: + session = await cls._require_session() + console_base = cls.console_base_url() + if not console_base: + return {"ok": True, "mode": "mock", "session": session} + token = str(session["console_session_token"]) + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/console-sessions/refresh", + headers={"Authorization": f"Bearer {token}"}, + json={"console_session_token": token}, + ) + if resp.status_code in {400, 401, 403, 404}: + await Storage.delete("console:session") + _delete_shared_console_session() + raise ValueError("console 会话已失效,请重新登录") + resp.raise_for_status() + data = resp.json() + now = _now_iso() + refreshed_session = { + **session, + "console_session_token": data.get("console_session_token") or session.get("console_session_token"), + "passport_uid": data.get("passport_uid") or session.get("passport_uid"), + "user_email": data.get("user_email", session.get("user_email")), + "user_display": data.get("user_display", session.get("user_display")), + "expires_at": data.get("expires_at"), + "refreshed_at": now, + "updated_at": now, + } + await Storage.set("console:session", refreshed_session, "json") + _write_shared_console_session(refreshed_session) + return refreshed_session + + @classmethod + async def logout_console_session(cls) -> None: + session = await cls.get_console_session() + console_base = cls.console_base_url() + if console_base and session: + token = str(session.get("console_session_token") or "").strip() + if token: + try: + async with httpx.AsyncClient(timeout=10) as client: + await client.post( + f"{console_base}/v1/console-sessions/revoke", + headers={"Authorization": f"Bearer {token}"}, + json={"console_session_token": token}, + ) + except Exception: + pass + await Storage.delete("console:session") + _delete_shared_console_session() + + @classmethod + async def require_console_session(cls) -> dict[str, Any]: + session = await cls._require_session() + account_name = session.get("user_display") or session.get("user_email") or session.get("passport_uid") + if not account_name: + raise ValueError("云账号未登录") + return session + + @classmethod + async def _require_session(cls) -> dict[str, Any]: + original = await cls.get_console_session() + if not isinstance(original, dict): + raise ValueError("云账号未登录") + session = dict(original) + token = str(session.get("console_session_token") or "").strip() + fingerprint = str(session.get("fingerprint") or "").strip() + install_id = str(session.get("install_id") or "").strip() + if not token or not fingerprint or not install_id: + raise ValueError("console 会话无效,请重新登录") + if token.startswith("mock-console-session-") and cls.console_base_url(): + raise ValueError("console 登录未完成远端 exchange,请重新登录") + return session + + @staticmethod + def _edition() -> str: + raw = (os.getenv("FLOCKS_EDITION") or "oss").strip().lower() + return "flockspro" if raw == "flockspro" else "oss" + + @staticmethod + def _runtime_version() -> str: + try: + from flocks.updater.updater import get_current_version + + version = str(get_current_version() or "").strip() + if version: + return version.lstrip("v") + except Exception: + pass + return str(__version__).lstrip("v") + + @classmethod + async def send_heartbeat(cls) -> dict[str, Any]: + session = await cls._require_session() + console_base = cls.console_base_url() + payload = { + "fingerprint": session["fingerprint"], + "install_id": session["install_id"], + "console_login_id": session.get("console_login_id"), + "sent_at": _now_iso(), + "status": "ok", + } + if not console_base: + return {"ok": True, "mode": "mock", "node": payload} + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/heartbeats", + json=payload, + headers={"Authorization": f"Bearer {session['console_session_token']}"}, + ) + if resp.status_code in {401, 403}: + raise ValueError("console 会话已失效,请重新登录") + resp.raise_for_status() + return resp.json() + + @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() + payload = { + "fingerprint": session["fingerprint"], + "install_id": session["install_id"], + "edition": cls._edition(), + "version": cls._runtime_version(), + "source": source, + "sent_at": _now_iso(), + } + if not console_base: + return {"ok": True, "mode": "mock", "node": payload} + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/nodes/sync", + json=payload, + headers={"Authorization": f"Bearer {session['console_session_token']}"}, + ) + if resp.status_code in {401, 403}: + raise ValueError("console 会话已失效,请重新登录") + resp.raise_for_status() + return resp.json() diff --git a/flocks/console/scheduler.py b/flocks/console/scheduler.py new file mode 100644 index 000000000..7bc98d878 --- /dev/null +++ b/flocks/console/scheduler.py @@ -0,0 +1,107 @@ +"""Background scheduler for console heartbeat and profile sync.""" + +from __future__ import annotations + +import asyncio +import time + +from flocks.console.login import ConsoleLoginService +from flocks.storage.storage import Storage +from flocks.utils.log import Log + +HEARTBEAT_INTERVAL_SECONDS = 3600 +SESSION_REFRESH_INTERVAL_SECONDS = 86400 +PROFILE_SYNC_INTERVAL_SECONDS = 86400 +SCHEDULER_TICK_SECONDS = 60 + +_HEARTBEAT_TS_KEY = "console:sync:last_heartbeat_ts" +_REFRESH_TS_KEY = "console:sync:last_session_refresh_ts" +_PROFILE_TS_KEY = "console:sync:last_profile_sync_ts" + +log = Log.create(service="console.sync.scheduler") + + +def _is_due(now_ts: int, last_ts: int | None, interval_seconds: int) -> bool: + if not last_ts: + return True + return now_ts - last_ts >= interval_seconds + + +class ConsoleSyncScheduler: + _task: asyncio.Task | None = None + + @classmethod + async def start(cls) -> None: + if cls._task and not cls._task.done(): + return + cls._task = asyncio.create_task(cls._run_loop(), name="console-sync-scheduler") + + @classmethod + async def stop(cls) -> None: + if not cls._task: + return + cls._task.cancel() + try: + await cls._task + except asyncio.CancelledError: + pass + cls._task = None + + @classmethod + async def _run_loop(cls) -> None: + while True: + await cls._tick_once() + await asyncio.sleep(SCHEDULER_TICK_SECONDS) + + @classmethod + async def _tick_once(cls) -> None: + now_ts = int(time.time()) + await cls._maybe_send_heartbeat(now_ts) + await cls._maybe_refresh_session(now_ts) + await cls._maybe_sync_profile(now_ts) + + @classmethod + async def _maybe_send_heartbeat(cls, now_ts: int) -> None: + raw_last = await Storage.get(_HEARTBEAT_TS_KEY) + last_ts = int(raw_last) if raw_last else None + if not _is_due(now_ts, last_ts, HEARTBEAT_INTERVAL_SECONDS): + return + try: + result = await ConsoleLoginService.send_heartbeat() + await Storage.set(_HEARTBEAT_TS_KEY, now_ts, "number") + log.info("console.sync.heartbeat.ok", {"at": now_ts, "result": result}) + except ValueError: + # Not bound / invalid session is expected and should not spam logs. + return + except Exception as exc: + log.warning("console.sync.heartbeat.failed", {"error": str(exc)}) + + @classmethod + async def _maybe_refresh_session(cls, now_ts: int) -> None: + raw_last = await Storage.get(_REFRESH_TS_KEY) + last_ts = int(raw_last) if raw_last else None + if not _is_due(now_ts, last_ts, SESSION_REFRESH_INTERVAL_SECONDS): + return + try: + result = await ConsoleLoginService.refresh_console_session() + await Storage.set(_REFRESH_TS_KEY, now_ts, "number") + log.info("console.sync.refresh.ok", {"at": now_ts, "result": result}) + except ValueError: + return + except Exception as exc: + log.warning("console.sync.refresh.failed", {"error": str(exc)}) + + @classmethod + async def _maybe_sync_profile(cls, now_ts: int) -> None: + raw_last = await Storage.get(_PROFILE_TS_KEY) + last_ts = int(raw_last) if raw_last else None + if not _is_due(now_ts, last_ts, PROFILE_SYNC_INTERVAL_SECONDS): + return + try: + result = await ConsoleLoginService.sync_node_profile(source="scheduled") + await Storage.set(_PROFILE_TS_KEY, now_ts, "number") + log.info("console.sync.profile.ok", {"at": now_ts, "result": result}) + except ValueError: + return + except Exception as exc: + log.warning("console.sync.profile.failed", {"error": str(exc)}) diff --git a/flocks/extensions.py b/flocks/extensions.py new file mode 100644 index 000000000..9c16b7a60 --- /dev/null +++ b/flocks/extensions.py @@ -0,0 +1,173 @@ +""" +Shared extension registration semantics for OSS and Flocks Pro integrations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Callable, Iterable, Optional + + +class FailPolicy(str, Enum): + """How an extension failure should affect the caller.""" + + ISOLATE = "isolate" + PROPAGATE = "propagate" + FAIL_CLOSED = "fail_closed" + + +@dataclass(frozen=True) +class ExtensionOptions: + """Common registration fields shared by hook-like extension points.""" + + name: str + priority: int = 100 + timeout_seconds: Optional[float] = None + fail_policy: FailPolicy = FailPolicy.ISOLATE + + @property + def critical(self) -> bool: + return self.fail_policy in {FailPolicy.PROPAGATE, FailPolicy.FAIL_CLOSED} + + +def normalize_fail_policy( + fail_policy: FailPolicy | str | None = None, + *, + critical: bool = False, +) -> FailPolicy: + """Normalize failure policy; ``critical`` is a compatibility alias.""" + + if fail_policy is None: + return FailPolicy.FAIL_CLOSED if critical else FailPolicy.ISOLATE + try: + return FailPolicy(fail_policy) + except ValueError as exc: + raise ValueError(f"无效失败策略: {fail_policy}") from exc + + +def normalize_timeout(timeout_seconds: Optional[float]) -> Optional[float]: + if timeout_seconds is None: + return None + timeout = float(timeout_seconds) + if timeout <= 0: + return None + return timeout + + +def handler_name(handler: Callable[..., Any], explicit_name: Optional[str] = None) -> str: + if explicit_name: + return explicit_name + module = getattr(handler, "__module__", "") + qualname = getattr(handler, "__qualname__", None) or getattr(handler, "__name__", None) + if module and qualname: + return f"{module}.{qualname}" + return str(handler) + + +def ensure_callable_methods(target: Any, method_names: Iterable[str], *, label: str) -> None: + """Conservative contract check used at extension registration time.""" + + missing = [name for name in method_names if not callable(getattr(target, name, None))] + if missing: + target_name = getattr(target, "__name__", str(target)) + raise ValueError(f"{label} 接口不完整: {target_name} 缺少 {', '.join(missing)}") + + +def register_auth_backend(backend: Any) -> None: + from flocks.auth import register_backend + + register_backend(backend) + + +def register_license_checker(checker: Any) -> None: + from flocks.license import register_checker + + register_checker(checker) + + +def register_audit_sink(sink: Any) -> None: + from flocks.audit import register_sink + + register_sink(sink) + + +def register_http_hook( + hook: Callable[..., Any], + *, + name: Optional[str] = None, + priority: int = 100, + timeout_seconds: Optional[float] = None, + fail_policy: FailPolicy | str | None = None, + critical: bool = False, +) -> None: + from flocks.server.app import register_http_middleware + + register_http_middleware( + hook, + name=name, + priority=priority, + timeout_seconds=timeout_seconds, + fail_policy=fail_policy, + critical=critical, + ) + + +def register_lifecycle_hook( + name: str, + hook: Any, + *, + priority: int = 0, + timeout_seconds: Optional[float] = None, + fail_policy: FailPolicy | str | None = None, + critical: bool = False, +) -> None: + from flocks.hooks.pipeline import HookPipeline + + HookPipeline.register( + name, + hook, + order=priority, + timeout_seconds=timeout_seconds, + fail_policy=fail_policy, + critical=critical, + ) + + +def register_event_hook( + event_key: str, + handler: Callable[..., Any], + *, + name: Optional[str] = None, + priority: int = 100, + timeout_seconds: Optional[float] = None, + fail_policy: FailPolicy | str | None = None, + critical: bool = False, + metadata: Optional[dict[str, Any]] = None, +) -> None: + from flocks.hooks.registry import register_hook + + hook_metadata = dict(metadata or {}) + hook_metadata.update({ + "name": name or handler_name(handler), + "priority": priority, + "timeout_seconds": timeout_seconds, + "fail_policy": normalize_fail_policy(fail_policy, critical=critical).value, + }) + register_hook(event_key, handler, hook_metadata) + + +__all__ = [ + "ExtensionOptions", + "FailPolicy", + "ensure_callable_methods", + "handler_name", + "normalize_fail_policy", + "normalize_timeout", + "register_audit_sink", + "register_auth_backend", + "register_http_hook", + "register_license_checker", + "register_lifecycle_hook", + "register_event_hook", +] diff --git a/flocks/hooks/pipeline.py b/flocks/hooks/pipeline.py index 56c920b9d..1a8c7eb84 100644 --- a/flocks/hooks/pipeline.py +++ b/flocks/hooks/pipeline.py @@ -11,11 +11,14 @@ - event """ +import asyncio +import inspect from dataclasses import dataclass, field from pathlib import Path import time from typing import Any, Dict, List, Optional, Callable, Awaitable +from flocks.extensions import FailPolicy, normalize_fail_policy, normalize_timeout from flocks.utils.log import Log @@ -34,6 +37,19 @@ class HookStage: CHANNEL_OUTBOUND_AFTER = "channel.outbound.after" +_DEFAULT_STAGE_TIMEOUTS: Dict[str, float] = { + HookStage.CHAT_MESSAGE: 5.0, + HookStage.LLM_BEFORE: 5.0, + HookStage.LLM_AFTER: 5.0, + HookStage.TOOL_BEFORE: 5.0, + HookStage.TOOL_AFTER: 5.0, + HookStage.CHANNEL_INBOUND: 5.0, + HookStage.CHANNEL_OUTBOUND_BEFORE: 5.0, + HookStage.CHANNEL_OUTBOUND_AFTER: 5.0, + HookStage.EVENT: 10.0, +} + + @dataclass class HookContext: stage: str @@ -74,7 +90,9 @@ async def channel_outbound_after(self, ctx: HookContext) -> None: # pragma: no class _HookEntry: order: int name: str - hook: HookBase + hook: HookBase = field(compare=False) + timeout_seconds: Optional[float] = field(default=None, compare=False) + fail_policy: FailPolicy = field(default=FailPolicy.ISOLATE, compare=False) class HookPipeline: @@ -95,13 +113,22 @@ def register( order: int = 0, *, plugin_managed: bool = False, + timeout_seconds: Optional[float] = None, + fail_policy: FailPolicy | str | None = None, + critical: bool = False, ) -> None: cls.unregister(name) if plugin_managed: cls._plugin_hook_names.add(name) else: cls._plugin_hook_names.discard(name) - cls._hooks.append(_HookEntry(order=order, name=name, hook=hook)) + cls._hooks.append(_HookEntry( + order=order, + name=name, + hook=hook, + timeout_seconds=normalize_timeout(timeout_seconds), + fail_policy=normalize_fail_policy(fail_policy, critical=critical), + )) cls._hooks.sort() log.info("hook.registered", {"name": name, "order": order}) @@ -289,7 +316,7 @@ async def _run_stage( input_data: Dict[str, Any], output_data: Optional[Dict[str, Any]] = None, ) -> HookContext: - started_at = time.perf_counter() + stage_started_at = time.perf_counter() project_dir = await cls._resolve_project_dir(input_data) await cls.ensure_initialized(project_dir) ctx = HookContext(stage=stage, input=input_data, output=output_data or {}) @@ -300,19 +327,43 @@ async def _run_stage( continue handler_count += 1 try: - result = handler(ctx) - if isinstance(result, Awaitable): - await result + timeout_seconds = entry.timeout_seconds + if timeout_seconds is None: + timeout_seconds = _DEFAULT_STAGE_TIMEOUTS.get(stage, 5.0) + handler_started_at = time.perf_counter() + if timeout_seconds is not None: + await asyncio.wait_for( + cls._invoke_handler(handler, ctx), + timeout=timeout_seconds, + ) + else: + await cls._invoke_handler(handler, ctx) + except asyncio.TimeoutError: + duration_ms = int((time.perf_counter() - handler_started_at) * 1000) + log.warning("hook.timeout", { + "stage": stage, + "hook": entry.name, + "duration_ms": duration_ms, + "timeout_ms": int((timeout_seconds or 0) * 1000), + "critical": entry.fail_policy != FailPolicy.ISOLATE, + "fail_policy": entry.fail_policy.value, + }) + if entry.fail_policy != FailPolicy.ISOLATE: + raise except Exception as exc: log.error("hook.error", { "stage": stage, "hook": entry.name, "error": str(exc), + "critical": entry.fail_policy != FailPolicy.ISOLATE, + "fail_policy": entry.fail_policy.value, }) + if entry.fail_policy != FailPolicy.ISOLATE: + raise log.debug("hook.stage_complete", { "stage": stage, "handler_count": handler_count, - "duration_ms": int((time.perf_counter() - started_at) * 1000), + "duration_ms": int((time.perf_counter() - stage_started_at) * 1000), }) return ctx @@ -351,6 +402,12 @@ def _consume_hooks(items: list, source: str) -> None: max_depth=2, )) + @staticmethod + async def _invoke_handler(handler: Callable[[HookContext], Awaitable[None]], ctx: HookContext) -> None: + result = handler(ctx) + if inspect.isawaitable(result): + await result + @staticmethod def _resolve_handler(hook: HookBase, stage: str) -> Optional[Callable[[HookContext], Awaitable[None]]]: if stage == HookStage.CHAT_MESSAGE: diff --git a/flocks/hooks/registry.py b/flocks/hooks/registry.py index f71787aed..3f29924c9 100644 --- a/flocks/hooks/registry.py +++ b/flocks/hooks/registry.py @@ -5,14 +5,18 @@ Based on OpenClaw's internal-hooks.ts design. """ -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import asyncio +import time +from flocks.extensions import FailPolicy, normalize_fail_policy, normalize_timeout from flocks.hooks.types import HookEvent, AsyncHookHandler from flocks.utils.log import Log log = Log.create(service="hooks.registry") +DEFAULT_EVENT_HOOK_TIMEOUT_SECONDS = 10.0 + class HookRegistry: """ @@ -67,17 +71,53 @@ def register( """ if event_key not in self._handlers: self._handlers[event_key] = [] - - self._handlers[event_key].append(handler) - + handlers = self._handlers[event_key] + metadata = dict(metadata or {}) + handler_id = self._handler_id(event_key, handler) + hook_name = metadata.get("name") + + if hook_name: + for idx, existing in enumerate(list(handlers)): + existing_id = self._handler_id(event_key, existing) + existing_meta = self._metadata.get(existing_id, {}) + if existing_meta.get("name") == hook_name: + handlers[idx] = handler + self._metadata.pop(existing_id, None) + self._metadata[handler_id] = metadata + self._sort_handlers(event_key) + log.info("hooks.registered", { + "event_key": event_key, + "handler": self._handler_name(handler), + "name": hook_name, + "replaced": True, + "total_handlers": len(handlers), + }) + return + + if handler in handlers: + if metadata: + self._metadata[handler_id] = metadata + self._sort_handlers(event_key) + log.debug("hooks.register_skipped_duplicate", { + "event_key": event_key, + "handler": self._handler_name(handler), + "name": hook_name, + "total_handlers": len(handlers), + }) + return + + handlers.append(handler) + # Save metadata if metadata: - handler_id = f"{event_key}:{id(handler)}" self._metadata[handler_id] = metadata + + self._sort_handlers(event_key) log.info("hooks.registered", { "event_key": event_key, - "handler": handler.__name__ if hasattr(handler, '__name__') else str(handler), + "handler": self._handler_name(handler), + "name": hook_name, "total_handlers": len(self._handlers[event_key]), }) @@ -108,9 +148,7 @@ def unregister( del self._handlers[event_key] # Clean up metadata - handler_id = f"{event_key}:{id(handler)}" - if handler_id in self._metadata: - del self._metadata[handler_id] + self._metadata.pop(self._handler_id(event_key, handler), None) log.info("hooks.unregistered", {"event_key": event_key}) return True @@ -127,6 +165,8 @@ def clear(self, event_key: Optional[str] = None) -> None: """ if event_key: if event_key in self._handlers: + for handler in self._handlers[event_key]: + self._metadata.pop(self._handler_id(event_key, handler), None) del self._handlers[event_key] log.info("hooks.cleared", {"event_key": event_key}) else: @@ -178,21 +218,50 @@ async def trigger(self, event: HookEvent) -> None: # Execute all handlers for handler in all_handlers: + meta = self._metadata_for(type_key, specific_key, handler) + hook_name = str(meta.get("name") or self._handler_name(handler)) + fail_policy = normalize_fail_policy( + meta.get("fail_policy"), + critical=bool(meta.get("critical", False)), + ) + if "timeout_seconds" in meta: + timeout_seconds = normalize_timeout(meta.get("timeout_seconds")) + else: + timeout_seconds = DEFAULT_EVENT_HOOK_TIMEOUT_SECONDS + started_at = time.perf_counter() try: # Check if coroutine function - if asyncio.iscoroutinefunction(handler): - await handler(event) + if timeout_seconds is not None: + await asyncio.wait_for( + self._invoke_handler(handler, event), + timeout=timeout_seconds, + ) else: - # Run sync function in thread pool - await asyncio.to_thread(handler, event) - + await self._invoke_handler(handler, event) + except asyncio.TimeoutError: + duration_ms = int((time.perf_counter() - started_at) * 1000) + log.warning("hooks.handler_timeout", { + "type": event.type, + "action": event.action, + "handler": self._handler_name(handler), + "hook_name": hook_name, + "duration_ms": duration_ms, + "timeout_ms": int((timeout_seconds or 0) * 1000), + "fail_policy": fail_policy.value, + }) + if fail_policy != FailPolicy.ISOLATE: + raise except Exception as e: log.error("hooks.handler_error", { "type": event.type, "action": event.action, - "handler": handler.__name__ if hasattr(handler, '__name__') else str(handler), + "handler": self._handler_name(handler), + "hook_name": hook_name, "error": str(e), + "fail_policy": fail_policy.value, }) + if fail_policy != FailPolicy.ISOLATE: + raise def get_stats(self) -> Dict: """Get hook system statistics""" @@ -213,6 +282,40 @@ def get_stats(self) -> Dict: return stats + @staticmethod + def _handler_id(event_key: str, handler: AsyncHookHandler) -> str: + return f"{event_key}:{id(handler)}" + + @staticmethod + def _handler_name(handler: AsyncHookHandler) -> str: + return handler.__name__ if hasattr(handler, "__name__") else str(handler) + + async def _invoke_handler(self, handler: AsyncHookHandler, event: HookEvent) -> None: + if asyncio.iscoroutinefunction(handler): + await handler(event) + else: + await asyncio.to_thread(handler, event) + + def _sort_handlers(self, event_key: str) -> None: + self._handlers[event_key].sort( + key=lambda handler: self._metadata.get( + self._handler_id(event_key, handler), + {}, + ).get("priority", 100) + ) + + def _metadata_for( + self, + type_key: str, + specific_key: str, + handler: AsyncHookHandler, + ) -> Dict[str, Any]: + return ( + self._metadata.get(self._handler_id(type_key, handler)) + or self._metadata.get(self._handler_id(specific_key, handler)) + or {} + ) + # Convenience functions diff --git a/flocks/license/__init__.py b/flocks/license/__init__.py new file mode 100644 index 000000000..d70bb8358 --- /dev/null +++ b/flocks/license/__init__.py @@ -0,0 +1,80 @@ +""" +License checker facade (OSS default: always active). +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from flocks.extensions import ensure_callable_methods + + +@runtime_checkable +class LicenseChecker(Protocol): + @classmethod + async def is_active(cls, feature: str | None = None) -> bool: ... + + @classmethod + async def assert_active(cls, feature: str | None = None) -> None: ... + + @classmethod + async def status(cls) -> dict[str, Any]: ... + + +class AlwaysOkLicenseChecker: + @classmethod + async def is_active(cls, feature: str | None = None) -> bool: + return True + + @classmethod + async def assert_active(cls, feature: str | None = None) -> None: + return None + + @classmethod + async def status(cls) -> dict[str, Any]: + return {"activated": True, "active": True, "status": "oss"} + + +class _LicenseService: + _checker: type[LicenseChecker] = AlwaysOkLicenseChecker + + @classmethod + def register_checker(cls, checker: type[LicenseChecker]) -> None: + if checker is None: + raise ValueError("checker 不能为空") + ensure_callable_methods(checker, ("is_active", "assert_active", "status"), label="license checker") + cls._checker = checker + + @classmethod + def get_checker(cls) -> type[LicenseChecker]: + return cls._checker + + @classmethod + async def is_active(cls, feature: str | None = None) -> bool: + return await cls._checker.is_active(feature=feature) + + @classmethod + async def assert_active(cls, feature: str | None = None) -> None: + await cls._checker.assert_active(feature=feature) + + @classmethod + async def status(cls) -> dict[str, Any]: + return await cls._checker.status() + + +register_checker = _LicenseService.register_checker +get_checker = _LicenseService.get_checker +is_license_active = _LicenseService.is_active +assert_license_active = _LicenseService.assert_active +license_status = _LicenseService.status + +__all__ = [ + "LicenseChecker", + "AlwaysOkLicenseChecker", + "register_checker", + "get_checker", + "is_license_active", + "assert_license_active", + "license_status", +] + diff --git a/flocks/plugin/loader.py b/flocks/plugin/loader.py index de3792a77..757112260 100644 --- a/flocks/plugin/loader.py +++ b/flocks/plugin/loader.py @@ -23,7 +23,9 @@ from __future__ import annotations import importlib +import importlib.metadata import importlib.util +import inspect from dataclasses import dataclass, field from pathlib import Path from types import ModuleType @@ -267,6 +269,9 @@ def load_all( if extra_sources: cls._load_sources_for_ext(ext, extra_sources, project_dir) + # 4. Installed package entry-points + cls._load_entry_points() + @classmethod def load_for_extension( cls, @@ -329,6 +334,43 @@ def load_default_for_extension(cls, attr_name: str) -> List[Any]: # Internal # ------------------------------------------------------------------ + @classmethod + def _load_entry_points(cls) -> None: + """ + Load installed package entry-points under ``flocks.plugins``. + + Entry-point target is expected to be callable, supporting either: + - ``fn(loader_cls)``, or + - ``fn()``. + """ + group = "flocks.plugins" + try: + eps = importlib.metadata.entry_points().select(group=group) + except Exception as e: + log.debug("plugin.entrypoints.scan_failed", {"group": group, "error": str(e)}) + return + + for ep in eps: + try: + target = ep.load() + except Exception as e: + log.warning("plugin.entrypoint.load_failed", {"name": ep.name, "error": str(e)}) + continue + + if not callable(target): + log.warning("plugin.entrypoint.not_callable", {"name": ep.name}) + continue + + try: + signature = inspect.signature(target) + if len(signature.parameters) >= 1: + target(cls) + else: + target() + log.info("plugin.entrypoint.loaded", {"name": ep.name, "group": group}) + except Exception as e: + log.warning("plugin.entrypoint.invoke_failed", {"name": ep.name, "error": str(e)}) + @classmethod def _load_sources_for_ext( cls, diff --git a/flocks/server/app.py b/flocks/server/app.py index 6824a3006..eb004816a 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -912,6 +912,7 @@ async def general_exception_handler(request: Request, exc: Exception): from flocks.server.routes.admin_users import router as admin_users_router from flocks.server.routes.notifications import router as notifications_router from flocks.server.routes.device import router as device_router +from flocks.server.routes.console_upgrade import router as console_upgrade_router # Original routes with /api/ prefix app.include_router(health_router, prefix="/api", tags=["Health"]) app.include_router(session_router, prefix="/api/session", tags=["Session"]) @@ -969,6 +970,7 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(notifications_router, prefix="/api/notifications", tags=["Notifications"]) # Device integration (named instances, SQL-backed) app.include_router(device_router, prefix="/api/devices", tags=["Device"]) +app.include_router(console_upgrade_router, prefix="/api/console", tags=["ConsoleUpgrade"]) # ============================================================ # TUI Compatible Routes (without /api/ prefix) @@ -1033,6 +1035,20 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(admin_users_router, prefix="/admin", tags=["Admin"]) +def _load_installed_package_plugins() -> None: + """Load package entry-point plugins before the app starts serving requests.""" + try: + from flocks.plugin import PluginLoader + + PluginLoader.load_all(project_dir=Path.cwd()) + log.info("plugins.installed.loaded") + except Exception as e: + log.warning("plugins.installed.load_failed", {"error": str(e)}) + + +_load_installed_package_plugins() + + @app.get("/", tags=["Root"]) async def root(): """Return basic API information.""" diff --git a/flocks/server/routes/auth.py b/flocks/server/routes/auth.py index d7ceaef94..83ec3df9d 100644 --- a/flocks/server/routes/auth.py +++ b/flocks/server/routes/auth.py @@ -4,12 +4,16 @@ from __future__ import annotations +from typing import Any + from fastapi import APIRouter, HTTPException, Request, Response, status from pydantic import BaseModel, Field from flocks.auth.service import AuthService, TEMP_PASSWORD_TTL_HOURS +from flocks.console.login import ConsoleLoginService from flocks.server.auth import ( clear_session_cookie, + require_admin, require_user, set_session_cookie, should_use_secure_cookie, @@ -18,6 +22,68 @@ router = APIRouter() +def _parse_event_type(event_type: str) -> tuple[str, str]: + if "." in event_type: + category, action = event_type.split(".", 1) + return category, action + return event_type, "event" + + +async def _emit_auth_audit_fallback(event_type: str, payload: dict[str, Any]) -> None: + """Persist auth audit directly when flocks audit sink is still no-op.""" + try: + from flocks.audit import NullAuditSink, get_sink + + sink_cls = get_sink() + if sink_cls is not NullAuditSink: + return + except Exception: + return + + try: + from flockspro.audit.service import AuditEvent + from flockspro.audit.sinks import SqliteAuditSink + except Exception: + # OSS or flockspro not installed: nothing to persist. + return + + category, action = _parse_event_type(event_type) + failed = "failed" in action or bool(payload.get("error") or payload.get("reason")) + user_id = payload.get("user_id") + username = payload.get("username") + session_id = payload.get("session_id") + event = AuditEvent( + event_type=event_type, + category=category, + action=action, + status="error" if failed else "ok", + result="failed" if failed else "success", + user_id=str(user_id) if user_id else None, + user_name=str(username) if username else None, + resource_type="session", + resource_id=str(session_id) if session_id else None, + session_id=str(session_id) if session_id else None, + ip=str(payload.get("ip")) if payload.get("ip") else None, + payload=payload, + metadata=payload, + ) + await SqliteAuditSink().write(event) + + +async def _emit_auth_audit(event_type: str, payload: dict) -> None: + try: + from flocks.audit import emit_audit_event + + await emit_audit_event(event_type, payload) + except Exception: + # Audit failures must not block auth flow. + pass + try: + await _emit_auth_audit_fallback(event_type, payload) + except Exception: + pass + + class BootstrapStatusResponse(BaseModel): bootstrapped: bool @@ -67,6 +133,31 @@ class ResetOwnPasswordResponse(BaseModel): must_reset_password: bool +class ConsoleLoginStartResponse(BaseModel): + console_login_id: str + passport_login_url: str + + +class ConsoleLoginFinishRequest(BaseModel): + console_login_id: str = Field(..., min_length=1) + state: str | None = None + passport_uid: str | None = None + + +class ConsoleLoginFinishResponse(BaseModel): + console_login_id: str + logged_in: bool + account_name: str | None = None + updated_at: str | None = None + + +class ConsoleLoginSessionResponse(BaseModel): + logged_in: bool + console_login_id: str | None = None + account_name: str | None = None + updated_at: str | None = None + + @router.get("/bootstrap-status", response_model=BootstrapStatusResponse, summary="获取本地账号初始化状态") async def bootstrap_status() -> BootstrapStatusResponse: status_obj = await AuthService.get_bootstrap_status() @@ -96,19 +187,64 @@ async def login(payload: LoginRequest, response: Response, request: Request) -> payload.password, ) except ValueError as exc: + await _emit_auth_audit( + "account.login_failed", + { + "username": payload.username, + "reason": str(exc), + "ip": getattr(getattr(request, "client", None), "host", None), + }, + ) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc set_session_cookie(response, session_id, secure=should_use_secure_cookie(request)) + await _emit_auth_audit( + "account.login", + { + "actor_id": user.username, + "actor_name": user.username, + "user_id": user.id, + "user_name": user.username, + "username": user.username, + "role": user.role, + "session_id": session_id, + "ip": getattr(getattr(request, "client", None), "host", None), + }, + ) return _to_me_response(user) +@router.get("/console-login/start", response_model=ConsoleLoginStartResponse, summary="发起 console 云账号登录") +async def console_login_start(request: Request, return_to: str | None = None) -> ConsoleLoginStartResponse: + require_admin(request) + resolved_return_to = return_to or "/flockspro-upgrade/callback" + try: + result = await ConsoleLoginService.start_console_login(return_to=resolved_return_to) + return ConsoleLoginStartResponse(**result) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + @router.post("/logout", summary="退出登录") async def logout(response: Response, request: Request) -> dict: - require_user(request) + user = require_user(request) session_id = request.cookies.get("flocks_session") if session_id: await AuthService.revoke_session(session_id) clear_session_cookie(response) + await _emit_auth_audit( + "account.logout", + { + "actor_id": user.username, + "actor_name": user.username, + "user_id": user.id, + "user_name": user.username, + "username": user.username, + "role": user.role, + "session_id": session_id, + "ip": getattr(getattr(request, "client", None), "host", None), + }, + ) return {"success": True} @@ -119,6 +255,60 @@ async def me(request: Request) -> MeResponse: return _to_me_response(full_user or user) +@router.post("/console-login/finish", response_model=ConsoleLoginFinishResponse, summary="完成 console 云账号登录 exchange") +async def console_login_finish( + payload: ConsoleLoginFinishRequest, + request: Request, +) -> ConsoleLoginFinishResponse: + require_admin(request) + try: + result = await ConsoleLoginService.finish_console_login( + console_login_id=payload.console_login_id, + state=payload.state, + passport_uid=payload.passport_uid, + ) + try: + await ConsoleLoginService.send_heartbeat() + await ConsoleLoginService.sync_node_profile(force=True, source="login") + except Exception: + # Login success must not depend on best-effort telemetry delivery. + pass + account_name = result.get("user_display") or result.get("user_email") or result.get("passport_uid") + return ConsoleLoginFinishResponse( + console_login_id=payload.console_login_id, + logged_in=True, + account_name=account_name, + updated_at=result.get("updated_at"), + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.get("/console-login/session", response_model=ConsoleLoginSessionResponse, summary="查询本地 console 登录状态") +async def console_login_session(request: Request) -> ConsoleLoginSessionResponse: + require_admin(request) + session = await ConsoleLoginService.get_console_session() + if not session: + return ConsoleLoginSessionResponse(logged_in=False) + account_name = session.get("user_display") or session.get("user_email") or session.get("passport_uid") + if not account_name: + # 严格二态:没有账号名时视为未登录 + return ConsoleLoginSessionResponse(logged_in=False) + return ConsoleLoginSessionResponse( + logged_in=True, + console_login_id=session.get("console_login_id"), + account_name=account_name, + updated_at=session.get("updated_at"), + ) + + +@router.post("/console-login/logout", summary="退出 console 云账号登录") +async def console_login_logout(request: Request) -> dict: + require_admin(request) + await ConsoleLoginService.logout_console_session() + return {"success": True} + + @router.post("/change-password", summary="修改当前用户密码") async def change_password(payload: ChangePasswordRequest, response: Response, request: Request) -> dict: user = require_user(request) diff --git a/flocks/server/routes/console_upgrade.py b/flocks/server/routes/console_upgrade.py new file mode 100644 index 000000000..88018adbb --- /dev/null +++ b/flocks/server/routes/console_upgrade.py @@ -0,0 +1,969 @@ +"""Console upgrade request orchestration routes (OSS-side).""" + +from __future__ import annotations + +import asyncio +import base64 +import importlib.util +import os +import json +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Optional, Literal +from uuid import uuid4 + +import httpx +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from flocks.console.login import ConsoleLoginService +from flocks.server.auth import require_admin +from flocks.storage.storage import Storage +from flocks.updater import perform_pro_bundle_install + +router = APIRouter() +_AUTO_UPGRADE_TASKS: set[asyncio.Task[None]] = set() +_AUTO_UPGRADE_REQUEST_IDS: set[str] = set() + + +def _console_base_url() -> str: + raw = os.getenv("FLOCKS_CONSOLE_BASE_URL", "").strip().rstrip("/") + if not raw: + return "" + if raw.startswith(("http://", "https://")): + return raw + return f"https://{raw}" + + +class UpgradeRequestCreate(BaseModel): + product: str = Field(default="Flocks Pro", pattern="^Flocks Pro$") + license_type: Literal["trial_30d", "poc", "commercial"] + request_kind: Literal["new", "trial_extension", "license_change"] = "new" + company: str = Field(min_length=1) + applicant_name: str = Field(min_length=1) + applicant_email: Optional[str] = None + applicant_phone: Optional[str] = None + notes: Optional[str] = None + + +class UpgradeRequestStatus(BaseModel): + request_id: str + status: str + previous_request_id: Optional[str] = None + reason: Optional[str] = None + suggestion: Optional[str] = None + activate_key: Optional[str] = None + manifest_url: Optional[str] = None + license_id: Optional[str] = None + license_status: Optional[str] = None + max_admins: Optional[int] = None + max_members: Optional[int] = None + expires_at: Optional[int] = None + details: dict[str, Any] = Field(default_factory=dict) + created_at: str + updated_at: str + + +def _request_key(request_id: str) -> str: + return f"console:upgrade_request:{request_id}" + + +async def _list_request_ids() -> list[str]: + ids = await Storage.get("console:upgrade_request_ids") + if not isinstance(ids, list): + return [] + return [str(i) for i in ids] + + +async def _push_request_id(request_id: str) -> None: + ids = await _list_request_ids() + if request_id not in ids: + ids.append(request_id) + await Storage.set("console:upgrade_request_ids", ids, "json") + + +_INACTIVE_LICENSE_STATUSES = {"revoked", "expired", "superseded"} + + +def _is_approved(record: dict[str, Any]) -> bool: + return str(record.get("status", "")).strip().lower() == "approved" + + +def _record_license_id(record: dict[str, Any]) -> str: + details = record.get("details") if isinstance(record.get("details"), dict) else {} + return str(record.get("license_id") or details.get("license_id") or "").strip() + + +def _record_license_status(record: dict[str, Any]) -> str: + details = record.get("details") if isinstance(record.get("details"), dict) else {} + return str(record.get("license_status") or details.get("license_status") or "").strip().lower() + + +def _apply_console_license_data(record: dict[str, Any], data: dict[str, Any]) -> None: + if not data: + return + details = record.setdefault("details", {}) + effective_status = str( + data.get("effective_status") + or data.get("license_status") + or data.get("status") + or record.get("license_status") + or "" + ).strip() + if data.get("revoked"): + effective_status = "revoked" + effective_expires_at = data.get("effective_expires_at", data.get("expires_at")) + effective_max_admins = data.get("effective_max_admins", data.get("max_admins")) + effective_max_members = data.get("effective_max_members", data.get("max_members")) + + for key, value in { + "license_id": data.get("license_id"), + "license_status": effective_status or None, + "max_admins": effective_max_admins, + "max_members": effective_max_members, + "expires_at": effective_expires_at, + "activate_key": data.get("activate_key"), + "manifest_url": data.get("manifest_url"), + }.items(): + if value is not None: + record[key] = value + details[key] = value + if effective_expires_at is not None: + details["license_effective_expires_at"] = effective_expires_at + latest_patch = data.get("latest_patch") or data.get("latest_change") + if latest_patch: + details["latest_license_patch"] = latest_patch + record["updated_at"] = datetime.now(UTC).isoformat() + + +def _record_account_key(record: dict[str, Any]) -> str: + details = record.get("details") if isinstance(record.get("details"), dict) else {} + return str( + details.get("console_account_name") + or details.get("cloud_account") + or details.get("passport_uid") + or details.get("account") + or "" + ).strip().lower() + + +def _console_session_account_key(console_session: dict[str, Any]) -> str: + return str( + console_session.get("user_display") + or console_session.get("user_email") + or console_session.get("passport_uid") + or "" + ).strip().lower() + + +async def _latest_usable_issued_record( + revoked_license_ids: set[str], + *, + account_key: str = "", +) -> dict[str, Any] | None: + candidates: list[tuple[dict[str, Any], bool]] = [] + for request_id in await _list_request_ids(): + raw = await Storage.get(_request_key(request_id)) + if not isinstance(raw, dict): + continue + status = str(raw.get("status", "")).strip().lower() + license_id = _record_license_id(raw) + if status not in {"approved", "activated"} or not license_id or not raw.get("activate_key"): + continue + record_account_key = _record_account_key(raw) + if account_key and record_account_key and record_account_key != account_key: + continue + usable = license_id not in revoked_license_ids and _record_license_status(raw) not in _INACTIVE_LICENSE_STATUSES + candidates.append((raw, usable)) + candidates.sort( + key=lambda item: _parse_dt(item[0].get("created_at") or item[0].get("updated_at")) + or datetime.min.replace(tzinfo=UTC), + reverse=True, + ) + if not candidates: + return None + for record, usable in candidates: + if usable: + return record + return None + + +def _is_pro_component_installed() -> bool: + try: + return importlib.util.find_spec("flockspro") is not None + except (ImportError, ValueError): + return False + + +def _get_pro_capability_status() -> dict[str, Any]: + if not _is_pro_component_installed(): + return { + "active": False, + "pro_enabled": False, + "license_status": "uninstalled", + "inactive_reason": "flockspro_not_installed", + } + try: + from flockspro.license.runtime import get_pro_capability_status # type: ignore[import-not-found] + + status_data = get_pro_capability_status() + return status_data if isinstance(status_data, dict) else {} + except Exception as exc: + return { + "active": False, + "pro_enabled": False, + "license_status": "unknown", + "inactive_reason": "capability_check_failed", + "error": str(exc), + } + + +def _record_pro_capability(details: dict[str, Any]) -> dict[str, Any]: + capability = _get_pro_capability_status() + details["pro_enabled"] = bool(capability.get("pro_enabled")) + details["runtime_license_status"] = capability.get("license_status") + details["runtime_license_inactive_reason"] = capability.get("inactive_reason") + return capability + + +def _decode_jwt_payload_unverified(token: str) -> dict[str, Any]: + try: + payload_b64 = token.split(".")[1] + padded = payload_b64 + "=" * (-len(payload_b64) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")) + payload = json.loads(raw.decode("utf-8")) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + +def _parse_dt(value: Any) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value, UTC) + text = str(value).strip() + if not text: + return None + try: + if text.endswith("Z"): + text = text[:-1] + "+00:00" + parsed = datetime.fromisoformat(text) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + except ValueError: + return None + + +def _license_duration_seconds(record: dict[str, Any]) -> int | None: + details = record.setdefault("details", {}) + payload = _decode_jwt_payload_unverified(str(record.get("activate_key") or details.get("activate_key") or "")) + duration_days = payload.get("duration_days") or details.get("license_duration_days") + if duration_days: + try: + return max(1, int(duration_days)) * 86400 + except (TypeError, ValueError): + pass + issued_at = payload.get("iat") or payload.get("issued_at") + expires_at = payload.get("expires_at") or details.get("expires_at") + try: + if issued_at and expires_at: + return max(1, int(expires_at) - int(issued_at)) + except (TypeError, ValueError): + return None + return None + + +def _enrich_record_from_install_marker(record: dict[str, Any]) -> dict[str, Any]: + details = record.setdefault("details", {}) + marker = _read_pro_bundle_install_marker() + if marker: + details.setdefault("auto_install_version", marker.get("installed_version")) + details.setdefault("auto_install_pro_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")) + + activated_source = details.get("license_activated_at") or details.get("auto_install_completed_at") + if not activated_source and marker: + activated_source = marker.get("installed_at") + activated_at = _parse_dt(activated_source) + duration_seconds = _license_duration_seconds(record) + if activated_at and duration_seconds: + effective_expires_at = int((activated_at + timedelta(seconds=duration_seconds)).timestamp()) + details["license_effective_expires_at"] = effective_expires_at + details["license_duration_days"] = max(1, round(duration_seconds / 86400)) + return record + + +async def _maybe_activate_pro_license(record: dict[str, Any], *, force: bool = False) -> None: + activate_key = str(record.get("activate_key") or "").strip() + if not activate_key: + return + details = record.setdefault("details", {}) + if details.get("license_activated_at") and not force: + return + try: + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] + + checker = get_license_checker() + activate_fn = getattr(checker, "activate", None) + if callable(activate_fn): + activation_receipt = details.get("activation_receipt") or record.get("activation_receipt") + if activation_receipt: + activate_fn(activate_key, activation_receipt) + else: + activate_fn(activate_key) + details["license_activated_at"] = datetime.now(UTC).isoformat() + details.pop("license_activate_error", None) + except Exception as exc: + details["license_activate_error"] = str(exc) + if not _is_pro_component_installed(): + return + _fallback_write_pro_license_state(record, activate_key, str(exc)) + + +def _fallback_write_pro_license_state(record: dict[str, Any], activate_key: str, reason: str) -> None: + details = record.setdefault("details", {}) + now = int(time.time()) + try: + from flockspro.license.cloud_checker import _machine_fingerprint # type: ignore[import-not-found] + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] + + checker = get_license_checker() + load_install_id = getattr(checker, "_load_or_create_install_id", None) + install_id = load_install_id() if callable(load_install_id) else str(record.get("install_id") or "") + fingerprint = _machine_fingerprint(install_id) + except Exception: + install_id = "" + fingerprint = "" + + license_path = Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))) / "flockspro" / "license.json" + license_path.parent.mkdir(parents=True, exist_ok=True) + activation_receipt = details.get("activation_receipt") or record.get("activation_receipt") + license_path.write_text( + json.dumps( + { + "license_id": record.get("license_id"), + "key": activate_key, + "payload": {}, + "bound_fingerprint": fingerprint, + "activation_receipt": activation_receipt, + "patches": [], + "activated_at": now, + "install_id": install_id, + "fingerprint": fingerprint, + "last_sync_at": now, + "max_observed_at": now, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + details["license_activate_fallback_saved_at"] = datetime.now(UTC).isoformat() + details["license_activate_fallback_reason"] = reason + + +async def _maybe_refresh_pro_license(record: dict[str, Any]) -> None: + details = record.setdefault("details", {}) + try: + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] + + checker = get_license_checker() + refresh_fn = getattr(checker, "refresh", None) + if callable(refresh_fn): + await refresh_fn() # type: ignore[misc] + details["license_refreshed_at"] = datetime.now(UTC).isoformat() + except Exception as exc: + details["license_refresh_error"] = str(exc) + + +async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: + details = record.setdefault("details", {}) + details["auto_install_result"] = "running" + details["auto_install_started_at"] = datetime.now(UTC).isoformat() + marker = _read_pro_bundle_install_marker() + if _is_pro_component_installed() and marker: + details["auto_install_result"] = "already_latest" + details["auto_install_version"] = marker.get("installed_version") + details["auto_install_completed_at"] = datetime.now(UTC).isoformat() + _record_pro_capability(details) + await _report_pro_bundle_installation(record, install_result="success") + return record + + final_stage = "" + final_message = "" + async for progress in perform_pro_bundle_install(restart=False): + final_stage = progress.stage + final_message = progress.message + if progress.stage == "error": + raise ValueError(progress.message) + + await _maybe_activate_pro_license(record) + await _maybe_refresh_pro_license(record) + capability = _record_pro_capability(details) + marker = _read_pro_bundle_install_marker() + details["auto_install_result"] = ( + "done" if final_stage == "done" and capability.get("pro_enabled") else "license_inactive" + ) + details["auto_install_version"] = marker.get("installed_version") + details["auto_install_pro_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) + await _report_pro_bundle_installation(record, install_result="success") + return record + + +def _read_pro_bundle_install_marker() -> dict[str, Any]: + marker = Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))) / "run" / "pro-bundle-installed.json" + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +async def _report_pro_bundle_installation( + record: dict[str, Any], + *, + install_result: str, + error_message: str | None = None, +) -> None: + details = record.setdefault("details", {}) + try: + console_session = await ConsoleLoginService.require_console_session() + except Exception as exc: + details["install_receipt_error"] = str(exc) + return + marker = _read_pro_bundle_install_marker() + payload = { + "license_id": record.get("activate_key"), + "fingerprint": console_session.get("fingerprint"), + "install_id": console_session.get("install_id"), + "installed_version": marker.get("installed_version") or details.get("auto_install_target") or details.get("auto_install_version") or "", + "oss_version": marker.get("oss_version"), + "flockspro_component_version": marker.get("flockspro_component_version"), + "build_id": marker.get("build_id"), + "install_result": install_result, + "error_message": error_message, + "reported_at": datetime.now(UTC).isoformat(), + } + console_base = _console_base_url() + if not console_base: + details["install_receipt_error"] = "FLOCKS_CONSOLE_BASE_URL 未配置" + return + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post( + f"{console_base}/v1/pro-bundles/installations", + json=payload, + headers={"Authorization": f"Bearer {console_session['console_session_token']}"}, + ) + resp.raise_for_status() + details["install_receipt_reported_at"] = datetime.now(UTC).isoformat() + except Exception as exc: + details["install_receipt_error"] = str(exc) + + +async def _mark_console_upgrade_activated(record: dict[str, Any]) -> None: + request_id = str(record.get("request_id") or "").strip() + if not request_id: + return + console_base = _console_base_url() + if not console_base: + return + details = record.setdefault("details", {}) + try: + console_session = await ConsoleLoginService.require_console_session() + headers = {"Authorization": f"Bearer {console_session['console_session_token']}"} + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(f"{console_base}/v1/upgrade-requests/{request_id}/activate", headers=headers) + resp.raise_for_status() + details["console_activated_reported_at"] = datetime.now(UTC).isoformat() + except Exception as exc: + details["console_activated_report_error"] = str(exc) + + +async def _maybe_auto_activate_upgrade(record: dict[str, Any]) -> dict[str, Any]: + if not _is_approved(record): + return record + details = record.setdefault("details", {}) + if details.get("auto_install_result") in {"done", "already_latest"}: + return record + try: + await _maybe_activate_pro_license(record) + await _maybe_refresh_pro_license(record) + await _run_auto_upgrade_install(record) + capability = _record_pro_capability(details) + if capability.get("pro_enabled"): + record["status"] = "activated" + else: + details["auto_install_result"] = "license_inactive" + except Exception as exc: + details["auto_install_result"] = "failed" + details["auto_install_error"] = str(exc) + await _report_pro_bundle_installation(record, install_result="failed", error_message=str(exc)) + finally: + record["updated_at"] = datetime.now(UTC).isoformat() + return record + + +async def _run_auto_activate_upgrade_task(request_id: str, record: dict[str, Any]) -> None: + try: + updated = await _maybe_auto_activate_upgrade(record) + await Storage.set(_request_key(request_id), updated, "json") + except Exception as exc: + record.setdefault("details", {})["auto_install_error"] = str(exc) + record.setdefault("details", {})["auto_install_result"] = "failed" + record["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), record, "json") + finally: + _AUTO_UPGRADE_REQUEST_IDS.discard(request_id) + + +def _schedule_auto_activate_upgrade(request_id: str, record: dict[str, Any]) -> None: + if not _is_approved(record): + return + details = record.setdefault("details", {}) + if details.get("auto_install_result") in {"running", "done", "already_latest"}: + return + if request_id in _AUTO_UPGRADE_REQUEST_IDS: + return + _AUTO_UPGRADE_REQUEST_IDS.add(request_id) + task = asyncio.create_task(_run_auto_activate_upgrade_task(request_id, dict(record))) + _AUTO_UPGRADE_TASKS.add(task) + task.add_done_callback(_AUTO_UPGRADE_TASKS.discard) + + +def _raise_console_service_error(exc: Exception) -> None: + detail = "console 升级服务调用失败,请稍后重试" + if isinstance(exc, httpx.HTTPStatusError): + try: + payload = exc.response.json() + if isinstance(payload, dict): + detail = str(payload.get("detail") or payload.get("message") or detail) + except Exception: + if exc.response.text: + detail = exc.response.text + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail) from exc + + +@router.post("/upgrade-requests", response_model=UpgradeRequestStatus) +async def create_upgrade_request(payload: UpgradeRequestCreate, request: Request) -> UpgradeRequestStatus: + admin_user = require_admin(request) + try: + console_session = await ConsoleLoginService.require_console_session() + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + now = datetime.now(UTC).isoformat() + request_id = str(uuid4()) + normalized_product = payload.product.strip() or "Flocks Pro" + details = { + "product": normalized_product, + "license_type": payload.license_type, + "request_kind": payload.request_kind, + "company": payload.company.strip(), + "enterprise_name": payload.company.strip(), + "applicant_name": payload.applicant_name.strip(), + "applicant_email": (payload.applicant_email or "").strip() or None, + "applicant_phone": (payload.applicant_phone or "").strip() or None, + "notes": (payload.notes or "").strip() or None, + "idempotency_key": request_id, + "console_account_name": console_session.get("user_display") + or console_session.get("user_email") + or console_session.get("passport_uid"), + "cloud_account": console_session.get("user_display") + or console_session.get("user_email") + or console_session.get("passport_uid"), + "passport_uid": console_session.get("passport_uid"), + } + record = { + "request_id": request_id, + "status": "pending", + "previous_request_id": None, + "reason": details["notes"], + "suggestion": None, + "activate_key": None, + "manifest_url": None, + "license_id": None, + "license_status": None, + "max_admins": None, + "max_members": None, + "expires_at": None, + "details": details, + "created_at": now, + "updated_at": now, + } + + console_base = _console_base_url() + if console_base: + console_payload = { + "node_id": str(admin_user.id), + "console_login_id": console_session.get("console_login_id"), + "fingerprint": console_session.get("fingerprint"), + "install_id": console_session.get("install_id"), + "passport_uid": console_session.get("passport_uid"), + "company_name": details["company"], + "enterprise_name": details["enterprise_name"], + "contact_email": details["applicant_email"] or "", + "idempotency_key": request_id, + "form_data": details, + } + headers = {"Authorization": f"Bearer {console_session['console_session_token']}"} + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(f"{console_base}/v1/upgrade-requests", json=console_payload, headers=headers) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + _raise_console_service_error(exc) + else: + record.update( + { + "request_id": data.get("request_id", request_id), + "status": data.get("status", "pending"), + "reason": data.get("reason", details["notes"]), + "suggestion": data.get("suggestion"), + "activate_key": data.get("activate_key"), + "manifest_url": data.get("manifest_url"), + "license_id": data.get("license_id"), + "license_status": data.get("license_status"), + "max_admins": data.get("max_admins"), + "max_members": data.get("max_members"), + "expires_at": data.get("expires_at"), + "details": data.get("form_data", details), + "updated_at": datetime.now(UTC).isoformat(), + } + ) + + await Storage.set(_request_key(record["request_id"]), record, "json") + await _push_request_id(record["request_id"]) + return UpgradeRequestStatus(**record) + + +@router.get("/upgrade-requests", response_model=list[UpgradeRequestStatus]) +async def list_upgrade_requests(request: Request) -> list[UpgradeRequestStatus]: + require_admin(request) + result: list[UpgradeRequestStatus] = [] + for request_id in reversed(await _list_request_ids()): + raw = await Storage.get(_request_key(request_id)) + if raw: + result.append(UpgradeRequestStatus(**_enrich_record_from_install_marker(raw))) + return result + + +@router.get("/pro-package-status") +async def get_pro_package_status(request: Request) -> dict[str, Any]: + require_admin(request) + marker = _read_pro_bundle_install_marker() + capability = _get_pro_capability_status() + installed = _is_pro_component_installed() + return { + "installed": installed, + "installed_version": marker.get("installed_version"), + "flockspro_component_version": marker.get("flockspro_component_version"), + "build_id": marker.get("build_id"), + "installed_at": marker.get("installed_at"), + "pro_enabled": bool(capability.get("pro_enabled")), + "license_status": capability.get("license_status"), + "inactive_reason": capability.get("inactive_reason"), + } + + +@router.post("/licenses/sync-revocations") +async def sync_console_license_revocations(request: Request) -> dict[str, Any]: + require_admin(request) + console_base = _console_base_url() + if not console_base: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="FLOCKS_CONSOLE_BASE_URL 未配置") + try: + console_session = await ConsoleLoginService.require_console_session() + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + headers = {"Authorization": f"Bearer {console_session['console_session_token']}"} + account_key = _console_session_account_key(console_session) + synced_license_ids: list[str] = [] + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(f"{console_base}/v1/licenses/revocations", headers=headers) + resp.raise_for_status() + data = resp.json() + + for request_id in await _list_request_ids(): + raw = await Storage.get(_request_key(request_id)) + if not isinstance(raw, dict): + continue + record_account_key = _record_account_key(raw) + if account_key and record_account_key and record_account_key != account_key: + continue + license_id = _record_license_id(raw) + if not license_id: + continue + license_resp = await client.get(f"{console_base}/v1/licenses/{license_id}", headers=headers) + if license_resp.status_code == status.HTTP_404_NOT_FOUND: + continue + license_resp.raise_for_status() + license_data = license_resp.json() + if isinstance(license_data, dict): + _apply_console_license_data(raw, license_data) + synced_license_ids.append(license_id) + await Storage.set(_request_key(request_id), raw, "json") + except httpx.HTTPError as exc: + _raise_console_service_error(exc) + + revoked_license_ids = data.get("revoked_license_ids", []) + if not isinstance(revoked_license_ids, list): + revoked_license_ids = [] + + imported = False + activated_license_id: str | None = None + refreshed_license_id: str | None = None + if not _is_pro_component_installed(): + return { + "revoked_license_ids": [str(item) for item in revoked_license_ids], + "imported": imported, + "synced_license_ids": synced_license_ids, + "activated_license_id": activated_license_id, + "refreshed_license_id": refreshed_license_id, + "inactive_reason": "flockspro_not_installed", + } + try: + from flockspro.license.runtime import get_license_checker # type: ignore[import-not-found] + + checker = get_license_checker() + import_fn = getattr(checker, "import_revocation", None) + if callable(import_fn): + import_fn([str(item) for item in revoked_license_ids]) + imported = True + + current_status = _get_pro_capability_status() + current_license_id = str(current_status.get("license_id") or "") + current_inactive = ( + current_license_id in {str(item) for item in revoked_license_ids} + or str(current_status.get("license_status") or "").lower() in _INACTIVE_LICENSE_STATUSES + or not current_status.get("pro_enabled") + ) + if current_inactive: + target = await _latest_usable_issued_record( + {str(item) for item in revoked_license_ids}, + account_key=_console_session_account_key(console_session), + ) + target_license_id = _record_license_id(target) if target else "" + if target and target_license_id and target_license_id != current_license_id: + await _maybe_activate_pro_license(target, force=True) + await _maybe_refresh_pro_license(target) + activated_license_id = target_license_id + refreshed_license_id = target_license_id + await Storage.set(_request_key(str(target["request_id"])), target, "json") + except Exception as exc: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc + + return { + "revoked_license_ids": [str(item) for item in revoked_license_ids], + "imported": imported, + "synced_license_ids": synced_license_ids, + "activated_license_id": activated_license_id, + "refreshed_license_id": refreshed_license_id, + } + + +@router.get("/upgrade-requests/{request_id}", response_model=UpgradeRequestStatus) +async def get_upgrade_request(request_id: str, request: Request) -> UpgradeRequestStatus: + require_admin(request) + raw = await Storage.get(_request_key(request_id)) + if not raw: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="升级申请不存在") + return UpgradeRequestStatus(**_enrich_record_from_install_marker(raw)) + + +@router.post("/upgrade-requests/{request_id}/refresh", response_model=UpgradeRequestStatus) +async def refresh_upgrade_request(request_id: str, request: Request) -> UpgradeRequestStatus: + require_admin(request) + raw = await Storage.get(_request_key(request_id)) + if not raw: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="升级申请不存在") + + console_base = _console_base_url() + if console_base: + try: + console_session = await ConsoleLoginService.require_console_session() + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + headers = {"Authorization": f"Bearer {console_session['console_session_token']}"} + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + f"{console_base}/v1/upgrade-requests/{request_id}", + headers=headers, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + _raise_console_service_error(exc) + else: + raw.update( + { + "status": data.get("status", raw["status"]), + "reason": data.get("reason", raw.get("reason")), + "suggestion": data.get("suggestion", raw.get("suggestion")), + "activate_key": data.get("activate_key", raw.get("activate_key")), + "manifest_url": data.get("manifest_url", raw.get("manifest_url")), + "license_id": data.get("license_id", raw.get("license_id")), + "license_status": data.get("license_status", raw.get("license_status")), + "max_admins": data.get("max_admins", raw.get("max_admins")), + "max_members": data.get("max_members", raw.get("max_members")), + "expires_at": data.get("expires_at", raw.get("expires_at")), + "details": data.get("form_data", raw.get("details", {})), + "updated_at": datetime.now(UTC).isoformat(), + } + ) + else: + raw["updated_at"] = datetime.now(UTC).isoformat() + + _enrich_record_from_install_marker(raw) + await Storage.set(_request_key(request_id), raw, "json") + return UpgradeRequestStatus(**raw) + + +@router.post("/upgrade-requests/{request_id}/start") +async def start_upgrade_request(request_id: str, request: Request) -> StreamingResponse: + require_admin(request) + raw = await Storage.get(_request_key(request_id)) + if not raw: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="升级申请不存在") + if not _is_approved(raw): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅已审批通过的申请可以开始升级") + + async def _stream(): + details = raw.setdefault("details", {}) + details["auto_install_result"] = "running" + details["auto_install_started_at"] = datetime.now(UTC).isoformat() + raw["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), raw, "json") + try: + await _maybe_activate_pro_license(raw) + await _maybe_refresh_pro_license(raw) + async for progress in perform_pro_bundle_install(restart=True): + if progress.stage == "error": + details["auto_install_result"] = "failed" + details["auto_install_error"] = progress.message + raw["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), raw, "json") + await _report_pro_bundle_installation(raw, install_result="failed", error_message=progress.message) + elif progress.stage in {"done", "restarting"}: + marker = _read_pro_bundle_install_marker() + await _maybe_activate_pro_license(raw) + await _maybe_refresh_pro_license(raw) + capability = _record_pro_capability(details) + if capability.get("pro_enabled"): + details["auto_install_result"] = "restarting" if progress.stage == "restarting" else "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_completed_at"] = datetime.now(UTC).isoformat() + details["auto_install_message"] = progress.message + _enrich_record_from_install_marker(raw) + if capability.get("pro_enabled"): + raw["status"] = "activated" + raw["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), raw, "json") + await _report_pro_bundle_installation(raw, install_result="success") + if capability.get("pro_enabled"): + await _mark_console_upgrade_activated(raw) + yield f"data: {progress.model_dump_json()}\n\n" + await asyncio.sleep(0) + if progress.stage == "error": + return + except Exception as exc: + details["auto_install_result"] = "failed" + details["auto_install_error"] = str(exc) + raw["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), raw, "json") + await _report_pro_bundle_installation(raw, install_result="failed", error_message=str(exc)) + yield f"data: {json.dumps({'stage': 'error', 'message': str(exc), 'success': False})}\n\n" + + return StreamingResponse( + _stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/upgrade-requests/{request_id}/cancel", response_model=UpgradeRequestStatus) +async def cancel_upgrade_request(request_id: str, request: Request) -> UpgradeRequestStatus: + require_admin(request) + raw = await Storage.get(_request_key(request_id)) + if not raw: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="升级申请不存在") + + console_base = _console_base_url() + if console_base: + try: + console_session = await ConsoleLoginService.require_console_session() + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + headers = {"Authorization": f"Bearer {console_session['console_session_token']}"} + try: + async with httpx.AsyncClient(timeout=10) as client: + data: dict[str, Any] | None = None + resp = await client.post( + f"{console_base}/v1/upgrade-requests/{request_id}/withdraw", + headers=headers, + ) + if resp.status_code == status.HTTP_400_BAD_REQUEST: + latest_resp = await client.get( + f"{console_base}/v1/upgrade-requests/{request_id}", + headers=headers, + ) + if latest_resp.status_code == status.HTTP_200_OK: + latest_data = latest_resp.json() + latest_status = str(latest_data.get("status", "")).strip().lower() + # Console may reject withdraw for approved requests. + # Keep OSS UX actionable: treat this as a local cancel so user can re-apply. + if str(raw.get("status", "")).strip().lower() == "approved" and latest_status == "approved": + latest_data = {**latest_data, "status": "cancelled"} + data = latest_data + elif latest_resp.status_code == status.HTTP_404_NOT_FOUND: + data = {"status": "cancelled"} + else: + latest_resp.raise_for_status() + elif resp.status_code == status.HTTP_404_NOT_FOUND: + # Console may have lost this request (e.g. in-memory reset). Keep local UX consistent. + data = {"status": "cancelled"} + else: + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + _raise_console_service_error(exc) + else: + raw.update( + { + "status": data.get("status", "cancelled"), + "reason": data.get("reason", raw.get("reason")), + "suggestion": data.get("suggestion", raw.get("suggestion")), + "activate_key": data.get("activate_key", raw.get("activate_key")), + "manifest_url": data.get("manifest_url", raw.get("manifest_url")), + "license_id": data.get("license_id", raw.get("license_id")), + "license_status": data.get("license_status", raw.get("license_status")), + "max_admins": data.get("max_admins", raw.get("max_admins")), + "max_members": data.get("max_members", raw.get("max_members")), + "expires_at": data.get("expires_at", raw.get("expires_at")), + "details": data.get("form_data", raw.get("details", {})), + "updated_at": datetime.now(UTC).isoformat(), + } + ) + else: + raw["status"] = "cancelled" + raw["updated_at"] = datetime.now(UTC).isoformat() + await Storage.set(_request_key(request_id), raw, "json") + return UpgradeRequestStatus(**raw) + diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index b2de4a4d9..47038fbb4 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -14,8 +14,10 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, ConfigDict -from flocks.auth.context import get_current_auth_user +from flocks.auth.context import get_current_auth_user, set_current_auth_user, reset_current_auth_user from flocks.server.routes._timing import log_route_timing +from flocks.audit import emit_audit_event +from flocks.license import assert_license_active from flocks.session.session import Session, SessionInfo as SessionModel from flocks.session.policy import SessionPolicy from flocks.utils.log import Log @@ -114,7 +116,10 @@ class SessionResponse(BaseModel): revert: Optional[Dict[str, Any]] = Field(None, description="Revert state") category: str = Field("user", description="Session category: user or task") ownerUserID: Optional[str] = Field(None, description="Session owner user id") + ownerUsername: Optional[str] = Field(None, description="Session owner username") + canWrite: bool = Field(False, description="Whether current user can continue this session") canDelete: bool = Field(False, description="Whether current user can delete this session") + isShared: bool = Field(False, description="Whether this session is locally shared") def _session_to_response(session: SessionModel) -> SessionResponse: @@ -125,7 +130,9 @@ def _session_to_response(session: SessionModel) -> SessionResponse: They are retrieved from the latest user message in the session. """ current_user = get_current_auth_user() + can_write = SessionPolicy.can_write(session, current_user) can_delete = SessionPolicy.can_delete(session, current_user) + is_shared = SessionPolicy.is_local_shared(session) return SessionResponse( id=session.id, @@ -146,16 +153,50 @@ def _session_to_response(session: SessionModel) -> SessionResponse: permission=[p.model_dump() for p in session.permission] if session.permission else None, category=session.category, ownerUserID=session.owner_user_id, + ownerUsername=session.owner_username, + canWrite=can_write, canDelete=can_delete, + isShared=is_shared, ) +def _require_session_read_access(session: SessionModel, user) -> None: + if not SessionPolicy.can_read(session, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅会话所有者或受邀只读用户可访问会话") + + +def _require_session_write_access(session: SessionModel, user) -> None: + if not SessionPolicy.can_write(session, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅会话所有者可写,受邀用户为只读") + + def _is_hidden_from_session_manager(session: SessionModel) -> bool: """Return whether a session should be excluded from manager listings.""" metadata = session.metadata if isinstance(session.metadata, dict) else {} return bool(metadata.get("hideFromSessionManager")) +def _share_metadata(session: SessionModel, *, shared: bool, actor_user_id: str) -> Dict[str, Any]: + metadata = dict(session.metadata) if isinstance(session.metadata, dict) else {} + metadata["shared_local"] = shared + if shared: + metadata["shared_local_by"] = actor_user_id + metadata["shared_local_at"] = int(time.time() * 1000) + else: + metadata.pop("shared_local_by", None) + metadata.pop("shared_local_at", None) + return metadata + + +async def _get_session_by_id_unfiltered(session_id: str) -> Optional[SessionModel]: + """Fetch session by id while bypassing policy filtering.""" + token = set_current_auth_user(None) + try: + return await Session.get_by_id(session_id) + finally: + reset_current_auth_user(token) + + # ============================================================================= # Session CRUD Routes # ============================================================================= @@ -258,6 +299,7 @@ async def list_sessions( async def create_session(http_request: Request, request: Optional[SessionCreateRequest] = None) -> SessionResponse: """Create a new session""" current_user = require_user(http_request) + await assert_license_active(feature="session_create") import os if request is None: @@ -325,6 +367,22 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR ) log.info("session.created", {"session_id": session.id}) + try: + await emit_audit_event( + "session_action", + { + "action": "create", + "actor_id": current_user.username, + "actor_name": current_user.username, + "user_name": current_user.username, + "username": current_user.username, + "session_id": session.id, + "owner_user_id": current_user.id, + "project_id": session.project_id, + }, + ) + except Exception: + pass return _session_to_response(session) @@ -339,14 +397,14 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR async def get_session(sessionID: str, request: Request) -> SessionResponse: """Get session by ID""" _current_user = require_user(request) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_read_access(session, _current_user) return _session_to_response(session) @@ -356,17 +414,18 @@ async def get_session(sessionID: str, request: Request) -> SessionResponse: summary="Get session children", description="Get all child sessions forked from the specified parent", ) -async def get_session_children(sessionID: str) -> List[SessionResponse]: +async def get_session_children(sessionID: str, request: Request) -> List[SessionResponse]: """Get child sessions""" - session = await Session.get_by_id(sessionID) + current_user = require_user(request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_read_access(session, current_user) children = await Session.children(session.project_id, sessionID) - return [_session_to_response(s) for s in children] + return [_session_to_response(s) for s in children if SessionPolicy.can_read(s, current_user)] class TodoInfo(BaseModel): @@ -389,13 +448,13 @@ async def get_session_todos(sessionID: str, request: Request) -> List[TodoInfo]: """Get session todos""" from flocks.storage.storage import Storage _current_user = require_user(request) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_read_access(session, _current_user) try: todos = await Storage.read(["todo", sessionID]) if todos is None: @@ -417,13 +476,13 @@ async def update_session_todos(sessionID: str, todos: List[TodoInfo], request: R from flocks.storage.storage import Storage from flocks.server.routes.event import publish_event _current_user = require_user(request) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, _current_user) try: await Storage.write(["todo", sessionID], [t.model_dump() for t in todos]) @@ -447,7 +506,7 @@ async def update_session_todos(sessionID: str, todos: List[TodoInfo], request: R async def delete_session(sessionID: str, request: Request) -> bool: """Delete session by ID (returns true)""" current_user = require_user(request) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( @@ -456,7 +515,7 @@ async def delete_session(sessionID: str, request: Request) -> bool: ) if not SessionPolicy.can_delete(session, current_user): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员或会话所有者可删除会话") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅会话所有者可删除会话") await Session.delete(session.project_id, sessionID) @@ -485,6 +544,22 @@ async def delete_session(sessionID: str, request: Request) -> bool: }) log.info("session.deleted", {"session_id": sessionID}) + try: + await emit_audit_event( + "session_action", + { + "action": "delete", + "actor_id": current_user.username, + "actor_name": current_user.username, + "user_name": current_user.username, + "username": current_user.username, + "session_id": sessionID, + "owner_user_id": current_user.id, + "project_id": session.project_id, + }, + ) + except Exception: + pass return True @@ -505,16 +580,19 @@ class SessionUpdateRequest(BaseModel): async def update_session( sessionID: str, request: SessionUpdateRequest, + http_request: Request, ) -> SessionResponse: """Update session""" - existing = await Session.get_by_id(sessionID) + existing = await _get_session_by_id_unfiltered(sessionID) if not existing: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + current_user = require_user(http_request) + _require_session_write_access(existing, current_user) + updates = {} if request.title is not None: updates["title"] = request.title @@ -537,6 +615,72 @@ async def update_session( return _session_to_response(session) +@router.post( + "/{sessionID}/share-local", + response_model=SessionResponse, + summary="Share session locally", + description="Share this session to all local accounts as read-only", +) +async def share_session_local(sessionID: str, http_request: Request) -> SessionResponse: + current_user = require_user(http_request) + token = set_current_auth_user(current_user) + try: + existing = await Session.get_by_id(sessionID) + finally: + reset_current_auth_user(token) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_write_access(existing, current_user) + metadata = _share_metadata(existing, shared=True, actor_user_id=current_user.id) + session = await Session.update( + project_id=existing.project_id, + session_id=sessionID, + metadata=metadata, + ) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + return _session_to_response(session) + + +@router.post( + "/{sessionID}/unshare-local", + response_model=SessionResponse, + summary="Unshare session locally", + description="Cancel local sharing of this session", +) +async def unshare_session_local(sessionID: str, http_request: Request) -> SessionResponse: + current_user = require_user(http_request) + token = set_current_auth_user(current_user) + try: + existing = await Session.get_by_id(sessionID) + finally: + reset_current_auth_user(token) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_write_access(existing, current_user) + metadata = _share_metadata(existing, shared=False, actor_user_id=current_user.id) + session = await Session.update( + project_id=existing.project_id, + session_id=sessionID, + metadata=metadata, + ) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + return _session_to_response(session) + + # ============================================================================= # Session Actions # ============================================================================= @@ -546,7 +690,7 @@ async def update_session( summary="Abort session", description="Abort an active session and stop any ongoing processing", ) -async def abort_session(sessionID: str) -> bool: +async def abort_session(sessionID: str, http_request: Request) -> bool: """Abort session processing. Aborts both the SessionLoop (sets abort_event so the next step check @@ -561,6 +705,15 @@ async def abort_session(sessionID: str) -> bool: from flocks.session.session_loop import SessionLoop from flocks.server.routes.question import reject_session_questions + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_write_access(session, current_user) + # Abort the loop-level context (propagates to runner via shared abort_event) loop_aborted = SessionLoop.abort(sessionID) @@ -623,17 +776,19 @@ class InitRequest(BaseModel): summary="Initialize session", description="Analyze the current application and create an AGENTS.md file with project-specific agent configurations", ) -async def initialize_session(sessionID: str, request: InitRequest) -> bool: +async def initialize_session(sessionID: str, request: InitRequest, http_request: Request) -> bool: """Initialize session""" from flocks.session.runner import SessionRunner - - session = await Session.get_by_id(sessionID) + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + # Execute INIT command await SessionRunner.command( session_id=sessionID, @@ -653,15 +808,17 @@ async def initialize_session(sessionID: str, request: InitRequest) -> bool: summary="Fork session", description="Create a new session by forking at a specific message point", ) -async def fork_session(sessionID: str, request: Optional[ForkRequest] = None) -> SessionResponse: +async def fork_session(sessionID: str, http_request: Request, request: Optional[ForkRequest] = None) -> SessionResponse: """Fork session""" - session = await Session.get_by_id(sessionID) + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + message_id = request.messageID if request else None forked = await Session.fork(session.project_id, sessionID, message_id) @@ -711,20 +868,22 @@ class SummarizeRequest(BaseModel): summary="Summarize session", description="Generate a summary using AI compaction", ) -async def summarize_session(sessionID: str, request: SummarizeRequest) -> bool: +async def summarize_session(sessionID: str, request: SummarizeRequest, http_request: Request) -> bool: """Summarize session""" from flocks.project.bootstrap import instance_bootstrap from flocks.project.instance import Instance from flocks.server.routes.event import publish_event from flocks.session.message import Message, MessageRole - - session = await Session.get_by_id(sessionID) + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + # Get all messages to find current agent from last user message # This matches Flocks logic in session.ts:520-528 messages = await Message.list(sessionID) @@ -782,17 +941,19 @@ class RevertRequest(BaseModel): summary="Revert session", description="Revert session to a specific message point", ) -async def revert_session(sessionID: str, request: RevertRequest) -> SessionResponse: +async def revert_session(sessionID: str, request: RevertRequest, http_request: Request) -> SessionResponse: """Revert session""" from flocks.session.lifecycle.revert import SessionRevert - - session = await Session.get_by_id(sessionID) + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + updated = await SessionRevert.revert( session_id=sessionID, message_id=request.messageID, @@ -809,17 +970,19 @@ async def revert_session(sessionID: str, request: RevertRequest) -> SessionRespo summary="Unrevert session", description="Restore previously reverted messages", ) -async def unrevert_session(sessionID: str) -> SessionResponse: +async def unrevert_session(sessionID: str, http_request: Request) -> SessionResponse: """Unrevert session""" from flocks.session.lifecycle.revert import SessionRevert - - session = await Session.get_by_id(sessionID) + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + updated = await SessionRevert.unrevert(session_id=sessionID) log.info("session.unreverted", {"session_id": sessionID}) @@ -1002,12 +1165,22 @@ class MessageEditRequest(BaseModel): ) async def get_session_messages( sessionID: str, + http_request: Request, limit: Optional[int] = Query(None, ge=1, description="Maximum messages to return"), ) -> List[MessageWithParts]: """Get session messages""" from flocks.session.message import Message import os - + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_read_access(session, current_user) + try: messages_with_parts = await Message.list_with_parts(sessionID, include_archived=True) if limit: @@ -1131,11 +1304,20 @@ async def get_session_messages( summary="Get message", description="Get a specific message by ID", ) -async def get_message(sessionID: str, messageID: str) -> MessageWithParts: +async def get_message(sessionID: str, messageID: str, http_request: Request) -> MessageWithParts: """Get single message""" from flocks.session.message import Message import os - + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_read_access(session, current_user) + msg_with_parts = await Message.get_with_parts(sessionID, messageID) if msg_with_parts: msg = msg_with_parts.info @@ -1224,10 +1406,19 @@ async def get_message(sessionID: str, messageID: str) -> MessageWithParts: summary="Delete message part", description="Delete a specific part from a message", ) -async def delete_message_part(sessionID: str, messageID: str, partID: str) -> bool: +async def delete_message_part(sessionID: str, messageID: str, partID: str, http_request: Request) -> bool: """Delete message part""" from flocks.session.message import Message - + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_write_access(session, current_user) + try: await Message.remove_part(sessionID, messageID, partID) log.info("message.part.deleted", { @@ -1252,6 +1443,7 @@ async def update_message_part( messageID: str, partID: str, body: MessagePartInfo, + http_request: Request, ) -> MessagePartInfo: """Update message part""" if body.id != partID or body.messageID != messageID or body.sessionID != sessionID: @@ -1261,7 +1453,16 @@ async def update_message_part( ) from flocks.session.message import Message - + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found", + ) + _require_session_write_access(session, current_user) + try: await Message.update_part(sessionID, messageID, partID, **body.model_dump()) log.info("message.part.updated", { @@ -1541,6 +1742,7 @@ async def resend_session_message( sessionID: str, messageID: str, body: MessageEditRequest, + http_request: Request, ) -> Dict[str, str]: import os @@ -1564,12 +1766,14 @@ async def resend_session_message( detail="Only user messages can be resent", ) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found", ) + current_user = require_user(http_request) + _require_session_write_access(session, current_user) if SessionLoop.is_running(sessionID): raise HTTPException( @@ -1621,6 +1825,7 @@ async def _handle_resend() -> None: async def regenerate_session_message( sessionID: str, messageID: str, + http_request: Request, ) -> Dict[str, str]: import os @@ -1656,12 +1861,14 @@ async def regenerate_session_message( detail="Assistant parent message must be a user message", ) - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found", ) + current_user = require_user(http_request) + _require_session_write_access(session, current_user) if SessionLoop.is_running(sessionID): raise HTTPException( @@ -1706,7 +1913,7 @@ async def _handle_regenerate() -> None: summary="Send message", description="Send a new message and get AI response", ) -async def send_session_message(sessionID: str, request: PromptRequest): +async def send_session_message(sessionID: str, request: PromptRequest, http_request: Request): """ Send message to session @@ -1725,12 +1932,14 @@ async def send_session_message(sessionID: str, request: PromptRequest): import json import os - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) + current_user = require_user(http_request) + _require_session_write_access(session, current_user) working_directory = session.directory or os.getcwd() @@ -2791,17 +3000,20 @@ async def _run_session_control(output_event, parsed) -> bool: async def send_session_message_async( sessionID: str, request: PromptRequest, + http_request: Request, ): """Send message asynchronously - returns 202 immediately, response via SSE""" import os from flocks.input.events import UserInputEvent - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) + current_user = require_user(http_request) + _require_session_write_access(session, current_user) working_directory = session.directory or os.getcwd() @@ -2903,7 +3115,7 @@ class CommandRequest(BaseModel): summary="Send command", description="Execute a slash command in the session (returns 202, result via SSE)", ) -async def send_session_command(sessionID: str, request: CommandRequest): +async def send_session_command(sessionID: str, request: CommandRequest, http_request: Request): """ Execute a slash command. @@ -2924,12 +3136,14 @@ async def send_session_command(sessionID: str, request: CommandRequest): from flocks.input.events import UserInputEvent - session = await Session.get_by_id(sessionID) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found", ) + current_user = require_user(http_request) + _require_session_write_access(session, current_user) working_directory = session.directory or os.getcwd() @@ -3009,17 +3223,19 @@ class ShellRequest(BaseModel): summary="Run shell command", description="Execute a shell command in the session context", ) -async def run_shell_command(sessionID: str, request: ShellRequest): +async def run_shell_command(sessionID: str, request: ShellRequest, http_request: Request): """Run shell command""" from flocks.session.runner import SessionRunner - - session = await Session.get_by_id(sessionID) + + current_user = require_user(http_request) + session = await _get_session_by_id_unfiltered(sessionID) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found" ) - + _require_session_write_access(session, current_user) + model = None if request.model: model = {"providerID": request.model.providerID, "modelID": request.model.modelID} @@ -3237,7 +3453,7 @@ async def get_session_statistics(sessionID: str): @router.post("/{sessionID}/clear") -async def clear_session(sessionID: str): +async def clear_session(sessionID: str, http_request: Request): """ Clear session messages @@ -3245,12 +3461,14 @@ async def clear_session(sessionID: str): """ try: # Verify session exists - session_info = await Session.get_by_id(sessionID) + session_info = await _get_session_by_id_unfiltered(sessionID) if not session_info: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found", ) + current_user = require_user(http_request) + _require_session_write_access(session_info, current_user) # Use Message.clear which handles bulk deletion atomically from flocks.session.message import Message diff --git a/flocks/server/routes/update.py b/flocks/server/routes/update.py index cd48a042f..fb92ffa21 100644 --- a/flocks/server/routes/update.py +++ b/flocks/server/routes/update.py @@ -71,6 +71,8 @@ async def _error(msg: str): zipball_url: str | None = None tarball_url: str | None = None + bundle_sha256: str | None = None + bundle_format: str | None = None if target_version: version_to_apply = target_version @@ -85,6 +87,8 @@ async def _no_update(): version_to_apply = info.latest_version zipball_url = info.zipball_url tarball_url = info.tarball_url + bundle_sha256 = info.bundle_sha256 + bundle_format = info.bundle_format log.info("update.apply.start", {"target": version_to_apply}) @@ -93,6 +97,8 @@ async def _stream(): version_to_apply, zipball_url=zipball_url, tarball_url=tarball_url, + bundle_sha256=bundle_sha256, + bundle_format=bundle_format, locale=locale, ) try: diff --git a/flocks/session/policy.py b/flocks/session/policy.py index 22dff1645..ac088874d 100644 --- a/flocks/session/policy.py +++ b/flocks/session/policy.py @@ -44,20 +44,58 @@ def is_owner(session: "SessionInfo", user: Optional["AuthUser"]) -> bool: def is_admin(user: Optional["AuthUser"]) -> bool: return bool(user and user.role == "admin") + @staticmethod + def is_local_shared(session: "SessionInfo") -> bool: + metadata = getattr(session, "metadata", None) + if not isinstance(metadata, dict): + return False + return bool(metadata.get("shared_local")) + + @staticmethod + def _shared_read_user_ids(session: "SessionInfo") -> set[str]: + metadata = getattr(session, "metadata", None) + if not isinstance(metadata, dict): + return set() + raw = metadata.get("shared_read_access_user_ids", []) + if not isinstance(raw, list): + return set() + return {str(item) for item in raw if item} + + @classmethod + def is_shared_read_only(cls, session: "SessionInfo", user: Optional["AuthUser"]) -> bool: + if user is None: + return False + if cls.is_owner(session, user): + return False + if cls.is_local_shared(session): + return True + return user.id in cls._shared_read_user_ids(session) + @classmethod def can_read(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> bool: """ Whether the session should be visible in listings / fetch. - No auth context (CLI/internal runtime): keep legacy permissive behaviour. - - Admin: sees everything. - - Otherwise: owner only. + - Logged-in users: owner or local-shared readers. """ resolved = cls._resolve_user(user) if resolved is None: return True - if cls.is_admin(resolved): + if cls.is_owner(session, resolved): return True + return cls.is_shared_read_only(session, resolved) + + @classmethod + def can_write(cls, session: "SessionInfo", user: Optional["AuthUser"] = None) -> bool: + """ + Session write permission. + + Shared users are read-only. Only owner can write. + """ + resolved = cls._resolve_user(user) + if resolved is None: + return False return cls.is_owner(session, resolved) @classmethod @@ -65,4 +103,4 @@ def can_delete(cls, session: "SessionInfo", user: Optional["AuthUser"]) -> bool: resolved = cls._resolve_user(user) if resolved is None: return False - return cls.is_admin(resolved) or cls.is_owner(session, resolved) + return cls.is_owner(session, resolved) diff --git a/flocks/session/prompt.py b/flocks/session/prompt.py index a3a01e944..160077d63 100644 --- a/flocks/session/prompt.py +++ b/flocks/session/prompt.py @@ -259,10 +259,16 @@ def environment_stable( """Build stable workspace metadata that should stay cache-friendly.""" working_dir = directory or os.getcwd() is_git = vcs == "git" + + from flocks.workspace.manager import WorkspaceManager + ws = WorkspaceManager.get_instance() + outputs_dir = str(ws.get_default_outputs_dir()) + env_info = [ "Here is some useful information about the environment you are running in:", "", f" Source code directory: {working_dir}", + f" Workspace outputs directory: {outputs_dir}", f" Is directory a git repo: {'yes' if is_git else 'no'}", f" Platform: {platform.system().lower()}", "", diff --git a/flocks/tool/file/doc_parser.py b/flocks/tool/file/doc_parser.py index 3b3736ef5..c2daae337 100644 --- a/flocks/tool/file/doc_parser.py +++ b/flocks/tool/file/doc_parser.py @@ -158,10 +158,7 @@ def _sanitize_stem(path: Path) -> str: def _default_output_path(input_file: Path) -> Path: workspace = WorkspaceManager.get_instance() - workspace.ensure_dirs() - today = dt.date.today().isoformat() - output_dir = workspace.get_workspace_dir() / "outputs" / today - output_dir.mkdir(parents=True, exist_ok=True) + output_dir = workspace.get_default_outputs_dir() suffix = input_file.suffix.lower().lstrip(".") or "file" return output_dir / f"{_sanitize_stem(input_file)}_{suffix}.md" diff --git a/flocks/tool/file/write.py b/flocks/tool/file/write.py index 7dc082fa4..eee0379cf 100644 --- a/flocks/tool/file/write.py +++ b/flocks/tool/file/write.py @@ -9,11 +9,12 @@ import os from difflib import unified_diff +from typing import Optional from flocks.tool.registry import ( ToolRegistry, ToolCategory, ToolParameter, ParameterType, ToolResult, ToolContext ) -from flocks.tool.path_utils import resolve_tool_path, safe_relpath as _safe_relpath +from flocks.tool.path_utils import resolve_tool_path from flocks.utils.log import Log @@ -106,6 +107,92 @@ def trim_diff(diff: str) -> str: return "\n".join(trimmed_lines) +def _looks_like_filename_only_intent(raw_path: str, resolved_path: str, base_dir: str) -> bool: + """ + Best-effort detect "user gave only a filename (no directory)" intent. + + Cases considered filename-only: + - Relative path without any directory separator (e.g. ``hello.txt``) + - Absolute path that points to the source root + basename + (common when model auto-expands a bare filename to cwd/source dir) + """ + normalized = (raw_path or "").replace("\\", "/") + if not raw_path: + return False + if not os.path.isabs(raw_path): + return "/" not in normalized + try: + return os.path.realpath(os.path.dirname(resolved_path)) == os.path.realpath(base_dir) + except Exception: + return False + + +async def _resolve_owner_username(ctx: ToolContext) -> Optional[str]: + """Resolve owner username from auth context, then session ownership.""" + try: + from flocks.auth.context import get_current_auth_user + auth_user = get_current_auth_user() + if auth_user and getattr(auth_user, "username", None): + return str(auth_user.username) + except Exception: + pass + + session_id = getattr(ctx, "session_id", None) + if not session_id or session_id == "default": + return None + try: + from flocks.session.session import Session + + session = await Session.get_by_id(session_id) + if session and getattr(session, "owner_username", None): + return str(session.owner_username) + except Exception: + pass + return None + + +async def _maybe_redirect_to_default_outputs( + ctx: ToolContext, + *, + original_path: str, + resolved_path: str, + base_dir: str, +) -> str: + """ + For filename-only writes, force stable default output location. + + This prevents nondeterministic writes to source root when user did not + specify a directory and model expanded filename against cwd. + """ + if not _looks_like_filename_only_intent(original_path, resolved_path, base_dir): + return resolved_path + if os.path.exists(resolved_path): + # Existing file writes should remain explicit and deterministic. + return resolved_path + + filename = os.path.basename(original_path) or os.path.basename(resolved_path) + if not filename: + return resolved_path + + try: + from flocks.workspace.manager import WorkspaceManager + + owner_username = await _resolve_owner_username(ctx) + outputs_dir = WorkspaceManager.get_instance().get_default_outputs_dir( + username=owner_username + ) + redirected = str((outputs_dir / filename).resolve()) + if redirected != resolved_path: + log.info( + "write.default_output_redirect", + {"from": resolved_path, "to": redirected, "session_id": ctx.session_id}, + ) + return redirected + except Exception as exc: + log.debug("write.default_output_redirect.failed", {"error": str(exc)}) + return resolved_path + + @ToolRegistry.register_function( name="write", description=DESCRIPTION, @@ -162,6 +249,20 @@ async def write_tool( try: resolution = await resolve_tool_path(ctx, filePath) + if resolution.sandbox_root is None: + redirected_path = await _maybe_redirect_to_default_outputs( + ctx, + original_path=filePath, + resolved_path=resolution.resolved_path, + base_dir=resolution.base_dir, + ) + if redirected_path != resolution.resolved_path: + resolution = await resolve_tool_path( + ctx, + redirected_path, + base_dir=resolution.base_dir, + worktree=resolution.worktree, + ) except ValueError as exc: return ToolResult( success=False, diff --git a/flocks/tool/truncation.py b/flocks/tool/truncation.py index fe027963b..bea3e1099 100644 --- a/flocks/tool/truncation.py +++ b/flocks/tool/truncation.py @@ -46,7 +46,7 @@ def _ensure_output_dir() -> Path: global _OUTPUT_DIR if _OUTPUT_DIR is None: - base = WorkspaceManager.get_instance().get_workspace_dir() / "tool-output" + base = WorkspaceManager.get_instance().get_default_outputs_dir() / "tool-output" base.mkdir(parents=True, exist_ok=True) _OUTPUT_DIR = base return _OUTPUT_DIR diff --git a/flocks/updater/__init__.py b/flocks/updater/__init__.py index 8e742a105..ddf14df7a 100644 --- a/flocks/updater/__init__.py +++ b/flocks/updater/__init__.py @@ -14,6 +14,7 @@ get_current_version, get_latest_release, perform_update, + perform_pro_bundle_install, ) __all__ = [ @@ -27,4 +28,5 @@ "get_current_version", "get_latest_release", "perform_update", + "perform_pro_bundle_install", ] diff --git a/flocks/updater/models.py b/flocks/updater/models.py index 1af1fd60a..dd9771606 100644 --- a/flocks/updater/models.py +++ b/flocks/updater/models.py @@ -26,6 +26,8 @@ class VersionInfo(BaseModel): release_url: str | None = None zipball_url: str | None = None tarball_url: str | None = None + bundle_sha256: str | None = None + bundle_format: Literal["zip", "tar.gz"] | None = None error: str | None = None deploy_mode: DeployMode = "source" update_allowed: bool = True diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index 152181e11..4d4bb70ae 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -13,6 +13,7 @@ """ import asyncio +import importlib.util import json import os import re @@ -29,7 +30,7 @@ from datetime import datetime, timezone from pathlib import Path, PureWindowsPath from typing import Any, AsyncGenerator -from urllib.parse import quote +from urllib.parse import quote, urlparse import httpx @@ -67,6 +68,17 @@ log = Log.create(service="updater") +@dataclass(frozen=True) +class ConsoleManifestRelease: + version: str + release_notes: str | None + release_url: str | None + bundle_url: str + bundle_sha256: str | None + bundle_format: str + manifest: dict[str, Any] + + def _record_update_journal(message: str) -> None: """Append a human-readable line to ``update.log`` (see ``append_upgrade_text_log``).""" from flocks.utils.log import append_upgrade_text_log @@ -867,6 +879,35 @@ def _resolve_update_mirror_profile( return UpdateMirrorProfile(region=None, sources=sources) +async def _resolve_sources_for_edition(configured_sources: list[str]) -> list[str]: + """ + Resolve effective sources by runtime edition state. + + Flocks Pro mode is explicitly selected by the runtime edition and an + active Pro license. A bound console account or inactive Pro package alone + is still valid OSS state and must keep OSS sources. + """ + sources = list(configured_sources) + edition = (os.getenv("FLOCKS_EDITION") or "").strip().lower() + + if edition == "flockspro" and _is_flockspro_license_active(): + # Pro edition is hard-locked to console manifest bundle channel. + return ["console-manifest"] + + return sources + + +def _is_flockspro_license_active() -> bool: + try: + if importlib.util.find_spec("flockspro") is None: + return False + from flockspro.license.runtime import is_pro_feature_enabled # type: ignore[import-not-found] + + return bool(is_pro_feature_enabled()) + except Exception: + return False + + # ------------------------------------------------------------------ # # Release API — GitHub # ------------------------------------------------------------------ # @@ -961,6 +1002,40 @@ def _download_filename_for_url(url: str, filename: str) -> str: return str(Path(filename).with_suffix(".zip")) +def _archive_format_for_url(url: str, manifest_format: str | None = None) -> str: + normalized = (manifest_format or "").strip().lower().replace("tgz", "tar.gz") + if normalized in {"zip", "tar.gz"}: + return normalized + + path = urlparse(url).path.lower() + if path.endswith(".zip"): + return "zip" + if path.endswith(".tar.gz") or path.endswith(".tgz"): + return "tar.gz" + return "tar.gz" + + +def _archive_filename_for_format(latest_tag: str, fmt: str) -> str: + return f"flocks-{latest_tag}.{'zip' if fmt == 'zip' else 'tar.gz'}" + + +def _sha256_file(path: Path) -> str: + digest = __import__("hashlib").sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _verify_download_sha256(path: Path, expected_sha256: str | None) -> None: + expected = (expected_sha256 or "").strip().lower() + if not expected: + return + actual = _sha256_file(path).lower() + if actual != expected: + raise ValueError(f"bundle sha256 mismatch: expected {expected}, got {actual}") + + # ------------------------------------------------------------------ # # Release API — GitLab # ------------------------------------------------------------------ # @@ -1018,6 +1093,81 @@ async def _fetch_gitlab_release( ) +async def _load_console_session_token() -> str | None: + try: + from flocks.storage.storage import Storage + + session = await Storage.get("console:session") + token = session.get("console_session_token") if isinstance(session, dict) else None + return str(token).strip() if token else None + except Exception: + return None + + +async def _fetch_console_manifest_release_info() -> ConsoleManifestRelease: + """ + Fetch latest Pro bundle manifest from console. + """ + manifest_base = os.getenv("FLOCKS_CONSOLE_BASE_URL", "").rstrip("/") + if not manifest_base: + 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}" + headers: dict[str, str] = {} + token = await _load_console_session_token() + if token: + headers["Authorization"] = f"Bearer {token}" + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, headers=headers, follow_redirects=True) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise ValueError("manifest 响应格式无效") + + if bool(data.get("frozen")): + raise ValueError("console manifest channel is frozen") + frozen_until_raw = str(data.get("frozen_until") or "").strip() + if frozen_until_raw: + text = frozen_until_raw[:-1] + "+00:00" if frozen_until_raw.endswith("Z") else frozen_until_raw + frozen_until = datetime.fromisoformat(text) + if frozen_until.tzinfo is None: + frozen_until = frozen_until.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) < frozen_until: + raise ValueError("console manifest channel frozen_until not reached") + + latest = str(data.get("compare_version") or data.get("display_version") or data.get("version") or data.get("latest_version") or "").strip() + if not latest: + raise ValueError("manifest 响应缺少 compare_version/display_version") + bundle_url = str( + data.get("bundle_url") + or data.get("url") + or data.get("zipball_url") + or data.get("tarball_url") + or "" + ).strip() + if not bundle_url: + raise ValueError("manifest 响应缺少 bundle_url") + bundle_format = _archive_format_for_url(bundle_url, str(data.get("bundle_format") or data.get("archive_format") or "")) + return ConsoleManifestRelease( + version=latest.lstrip("v"), + release_notes=data.get("release_notes") or data.get("notes"), + release_url=data.get("release_url") or bundle_url, + bundle_url=bundle_url, + bundle_sha256=str(data.get("bundle_sha256") or "").strip() or None, + bundle_format=bundle_format, + manifest=data, + ) + + +async def _fetch_console_manifest_release() -> tuple[str, str | None, str | None, str | None, str | None]: + info = await _fetch_console_manifest_release_info() + if info.bundle_format == "zip": + return info.version, info.release_notes, info.release_url, info.bundle_url, None + return info.version, info.release_notes, info.release_url, None, info.bundle_url + + # ------------------------------------------------------------------ # # Multi-source dispatcher # ------------------------------------------------------------------ # @@ -1038,6 +1188,8 @@ async def _fetch_release_from_source( return await _fetch_gitee_release(gitee_repo or repo, gitee_token) if source == "gitlab": return await _fetch_gitlab_release(repo, token, base_url) + if source == "console-manifest": + return await _fetch_console_manifest_release() raise ValueError(f"Unknown source: {source}") @@ -1060,6 +1212,13 @@ def _archive_url_for_source( base = (base_url or "https://gitlab.com").rstrip("/") proj = repo.split("/")[-1] return f"{base}/{repo}/-/archive/{raw_tag}/{proj}-{raw_tag}.{'zip' if fmt == 'zip' else 'tar.gz'}" + if source == "console-manifest": + manifest_base = os.getenv("FLOCKS_CONSOLE_BASE_URL", "").rstrip("/") + if not manifest_base: + raise ValueError("FLOCKS_CONSOLE_BASE_URL 未配置") + raw_tag = tag if tag.startswith("v") else f"v{tag}" + ext = "zip" if fmt == "zip" else "tar.gz" + return f"{manifest_base}/v1/manifest/archive/{raw_tag}.{ext}" raise ValueError(f"Unknown source: {source}") @@ -1158,6 +1317,25 @@ async def _download_archive( return dest +async def _download_console_bundle( + url: str, + token: str | None, + dest_dir: Path, + filename: str, +) -> Path: + """Download a console-hosted Pro bundle with the console session token.""" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / _download_filename_for_url(url, filename) + headers = {"Authorization": f"Bearer {token}"} if token else {} + async with httpx.AsyncClient(timeout=httpx.Timeout(30, read=300), follow_redirects=True) as client: + async with client.stream("GET", url, headers=headers) as resp: + resp.raise_for_status() + with dest.open("wb") as handle: + async for chunk in resp.aiter_bytes(chunk_size=65536): + handle.write(chunk) + return dest + + async def _download_with_fallback( sources: list[str], repo: str, @@ -1277,6 +1455,69 @@ def _detect_archive_root(extracted_dir: Path) -> Path: return extracted_dir +def _load_json_file(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _resolve_pro_bundle_content(content_root: Path) -> tuple[Path, Path | None, dict[str, Any]]: + """ + Return the OSS source root and optional flockspro wheel when an archive is a + Pro bundle. Plain OSS archives are returned unchanged. + """ + manifest_path = content_root / "manifest.json" + flocks_dir = content_root / "flocks" + if not manifest_path.is_file() or not flocks_dir.is_dir(): + return content_root, None, {} + + manifest = _load_json_file(manifest_path) + wheel_value = str(manifest.get("flockspro_wheel") or "").strip() + wheel_path = content_root / wheel_value if wheel_value else None + if wheel_path is None or not wheel_path.is_file(): + wheels = sorted((content_root / "wheels").glob("*.whl")) + wheel_path = wheels[0] if wheels else None + return flocks_dir, wheel_path, manifest + + +def _resolve_pro_bundle_wheel(content_root: Path) -> tuple[Path, dict[str, Any]]: + manifest_path = content_root / "manifest.json" + manifest = _load_json_file(manifest_path) if manifest_path.is_file() else {} + wheel_value = str(manifest.get("flockspro_wheel") or "").strip() + wheel_path = content_root / wheel_value if wheel_value else None + if wheel_path is None or not wheel_path.is_file(): + wheels = sorted((content_root / "wheels").glob("*.whl")) + wheel_path = wheels[0] if wheels else None + if wheel_path is None or not wheel_path.is_file(): + raise ValueError("Pro bundle 中未找到 flockspro wheel") + return wheel_path, manifest + + +def _is_pro_component_installed() -> bool: + return importlib.util.find_spec("flockspro") is not None + + +def _venv_python_path(install_root: Path) -> Path: + if sys.platform == "win32": + return install_root / ".venv" / "Scripts" / "python.exe" + return install_root / ".venv" / "bin" / "python" + + +def _write_pro_bundle_install_marker(manifest: dict[str, Any]) -> None: + marker = _flocks_root() / "run" / "pro-bundle-installed.json" + marker.parent.mkdir(parents=True, exist_ok=True) + payload = { + "installed_version": manifest.get("display_version"), + "oss_version": manifest.get("oss_version"), + "flockspro_component_version": manifest.get("flockspro_component_version"), + "build_id": manifest.get("build_id"), + "installed_at": datetime.now(timezone.utc).isoformat(), + } + marker.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") + + class _NullConsole: def print(self, *args, **kwargs) -> None: return None @@ -1296,6 +1537,35 @@ def _current_service_config(): ) +def _build_service_restart_argv(install_root: Path | None = None) -> list[str]: + repo_root = install_root or _get_repo_root() + if sys.platform == "win32": + venv_python = repo_root / ".venv" / "Scripts" / "python.exe" + else: + venv_python = repo_root / ".venv" / "bin" / "python" + + if not venv_python.exists(): + raise FileNotFoundError(f"Restart runtime is missing: {venv_python}") + + config = _current_service_config() + return [ + str(venv_python), + "-m", + "flocks.cli.main", + "restart", + "--no-browser", + "--skip-webui-build", + "--server-host", + str(config.backend_host), + "--server-port", + str(config.backend_port), + "--webui-host", + str(config.frontend_host), + "--webui-port", + str(config.frontend_port), + ] + + def _spawn_detached_process( command: list[str], *, @@ -1988,8 +2258,9 @@ async def check_update(*, locale: str | None = None, region: str | None = None) current = get_current_version() mode = detect_deploy_mode() ucfg = await _get_updater_config() + effective_sources = await _resolve_sources_for_edition(ucfg.sources) profile = _resolve_update_mirror_profile( - ucfg.sources, + effective_sources, region=region, locale=locale, ) @@ -2001,12 +2272,24 @@ async def check_update(*, locale: str | None = None, region: str | None = None) update_allowed=(mode != "docker"), ) + bundle_sha256: str | None = None + bundle_format: str | None = None try: - tag, notes, url, zipball, tarball = await get_latest_release( - repo=ucfg.repo, - token=ucfg.token, - sources_override=profile.sources, - ) + if profile.sources == ["console-manifest"]: + manifest_info = await _fetch_console_manifest_release_info() + tag = manifest_info.version + notes = manifest_info.release_notes + url = manifest_info.release_url + bundle_sha256 = manifest_info.bundle_sha256 + bundle_format = manifest_info.bundle_format + zipball = manifest_info.bundle_url if manifest_info.bundle_format == "zip" else None + tarball = manifest_info.bundle_url if manifest_info.bundle_format != "zip" else None + else: + tag, notes, url, zipball, tarball = await get_latest_release( + repo=ucfg.repo, + token=ucfg.token, + sources_override=profile.sources, + ) except Exception as exc: log.warning("updater.check_failed", {"error": str(exc)}) return VersionInfo( @@ -2017,6 +2300,16 @@ async def check_update(*, locale: str | None = None, region: str | None = None) ) has_update = _parse_version(tag) > _parse_version(current) + log.info( + "updater.check.result", + { + "current": current, + "latest": tag, + "has_update": has_update, + "sources": profile.sources, + "region": profile.region, + }, + ) return VersionInfo( current_version=current, latest_version=tag, @@ -2025,6 +2318,8 @@ async def check_update(*, locale: str | None = None, region: str | None = None) release_url=url, zipball_url=zipball, tarball_url=tarball, + bundle_sha256=bundle_sha256, + bundle_format=bundle_format if bundle_format in {"zip", "tar.gz"} else None, deploy_mode=mode, update_allowed=(mode != "docker"), ) @@ -2035,11 +2330,139 @@ async def check_update(*, locale: str | None = None, region: str | None = None) # ------------------------------------------------------------------ # +def _absolute_console_url(url: str) -> str: + if url.startswith(("http://", "https://", "file://")): + return url + manifest_base = os.getenv("FLOCKS_CONSOLE_BASE_URL", "").rstrip("/") + if not manifest_base: + return url + if url.startswith("/"): + return f"{manifest_base}{url}" + return f"{manifest_base}/{url}" + + +async def perform_pro_bundle_install( + *, + restart: bool = True, +) -> AsyncGenerator[UpdateProgress, None]: + """Install only the Flocks Pro wheel from a console bundle, then restart.""" + install_root = _get_repo_root() + tmp_dir = Path(tempfile.mkdtemp(prefix="flocks-pro-upgrade-")) + if sys.platform == "win32": + tmp_dir = _resolve_windows_long_path(tmp_dir) + + try: + try: + manifest_info = await _fetch_console_manifest_release_info() + except Exception as exc: + log.error("updater.pro_bundle.manifest_failed", {"error": str(exc)}) + yield UpdateProgress( + stage="error", + message="Failed to fetch the Flocks Pro bundle manifest.", + success=False, + ) + return + + if manifest_info.bundle_format != "zip": + yield UpdateProgress(stage="error", message="Flocks Pro bundle must be a zip archive.", success=False) + return + + yield UpdateProgress(stage="fetching", message="Downloading Flocks Pro bundle...") + token = await _load_console_session_token() + archive_path = await _download_console_bundle( + _absolute_console_url(manifest_info.bundle_url), + token, + tmp_dir, + _archive_filename_for_format(manifest_info.version, "zip"), + ) + await asyncio.to_thread(_verify_download_sha256, archive_path, manifest_info.bundle_sha256) + + extract_dir = tmp_dir / "extracted" + extract_dir.mkdir() + try: + content_root = await asyncio.to_thread(_extract_archive, archive_path, extract_dir) + pro_wheel_path, pro_bundle_manifest = _resolve_pro_bundle_wheel(content_root) + except Exception as exc: + yield UpdateProgress(stage="error", message=f"Failed to extract Flocks Pro bundle: {exc}", success=False) + return + + uv_path = _find_executable("uv") + if not uv_path: + yield UpdateProgress( + stage="error", + message="Flocks Pro install failed: uv is required but was not found.", + success=False, + ) + return + + yield UpdateProgress(stage="syncing", message="Installing Flocks Pro component...") + python_path = _venv_python_path(install_root) + install_cmd = [uv_path, "pip", "install", "--python", str(python_path), "--no-deps", str(pro_wheel_path)] + code, _, err = await _run_async( + install_cmd, + cwd=install_root, + timeout=180, + env=_build_uv_sync_env(), + ) + if code != 0: + yield UpdateProgress(stage="error", message=f"Flocks Pro component install failed: {err}", success=False) + return + + marker_manifest = dict(manifest_info.manifest) + marker_manifest.update(pro_bundle_manifest) + _write_pro_bundle_install_marker(marker_manifest) + + if sys.platform == "win32": + validation_error = await _validate_windows_restart_runtime(install_root) + if validation_error: + yield UpdateProgress(stage="error", message=validation_error, success=False) + return + + if not restart: + log.info("updater.pro_bundle.done", {"version": manifest_info.version, "restart": False}) + yield UpdateProgress( + stage="done", + message=f"Flocks Pro component installed from v{manifest_info.version}", + success=True, + ) + return + + try: + restart_argv = _build_service_restart_argv(install_root) + except Exception as exc: + log.error("updater.pro_bundle.restart.build_argv_failed", {"error": str(exc)}) + yield UpdateProgress(stage="error", message=f"Failed to build restart command: {exc}", success=False) + return + + def _restart_process() -> None: + log.info("updater.pro_bundle.restart.spawn", {"argv": restart_argv}) + try: + _spawn_detached_process( + restart_argv, + cwd=install_root, + log_path=_upgrade_log_dir() / "restart.log", + ) + except OSError as exc: + log.error("updater.pro_bundle.restart.failed", {"error": str(exc)}) + + log.info("updater.pro_bundle.restart", {"version": manifest_info.version}) + asyncio.get_running_loop().call_later(0.8, _restart_process) + yield UpdateProgress(stage="restarting", message="Restarting service...") + await asyncio.sleep(2) + except Exception as exc: + log.error("updater.pro_bundle.failed", {"error": str(exc)}) + yield UpdateProgress(stage="error", message=f"Flocks Pro upgrade failed: {exc}", success=False) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + async def perform_update( latest_tag: str, *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -2054,8 +2477,9 @@ async def perform_update( If one source fails the download, the next source is tried automatically. """ ucfg = await _get_updater_config() + effective_sources = await _resolve_sources_for_edition(ucfg.sources) profile = _resolve_update_mirror_profile( - ucfg.sources, + effective_sources, region=region, locale=locale, ) @@ -2063,7 +2487,35 @@ async def perform_update( current_version = get_current_version() handover_active = False + console_manifest_info: ConsoleManifestRelease | None = None fmt = _choose_archive_format(ucfg.archive_format) + if profile.sources == ["console-manifest"]: + if not (zipball_url or tarball_url): + try: + console_manifest_info = await _fetch_console_manifest_release_info() + except Exception as exc: + log.error("updater.console_manifest.fetch_failed", {"error": str(exc)}) + yield UpdateProgress( + stage="error", + message="Failed to check the Pro bundle manifest. Please check your network connection.", + success=False, + ) + return + if _parse_version(console_manifest_info.version) != _parse_version(latest_tag): + yield UpdateProgress( + stage="error", + message="Requested Pro bundle version does not match the latest approved manifest.", + success=False, + ) + return + if console_manifest_info.bundle_format == "zip": + zipball_url = console_manifest_info.bundle_url + else: + tarball_url = console_manifest_info.bundle_url + bundle_sha256 = console_manifest_info.bundle_sha256 + bundle_format = console_manifest_info.bundle_format + primary_bundle_url = zipball_url or tarball_url or "" + fmt = _archive_format_for_url(primary_bundle_url, bundle_format) # ------------------------------------------------------------------ # # Step 1 – download source archive @@ -2074,7 +2526,7 @@ async def perform_update( tmp_dir = Path(tempfile.mkdtemp(prefix="flocks-update-")) if sys.platform == "win32": tmp_dir = _resolve_windows_long_path(tmp_dir) - archive_filename = f"flocks-{latest_tag}.{fmt}" + archive_filename = _archive_filename_for_format(latest_tag, fmt) try: archive_path = await _download_with_fallback( sources=profile.sources, @@ -2090,6 +2542,8 @@ async def perform_update( base_url=ucfg.base_url, gitee_repo=ucfg.gitee_repo, ) + if profile.sources == ["console-manifest"]: + await asyncio.to_thread(_verify_download_sha256, archive_path, bundle_sha256) except Exception as exc: shutil.rmtree(tmp_dir, ignore_errors=True) log.error("updater.download.all_failed", {"error": str(exc)}) @@ -2132,6 +2586,7 @@ async def perform_update( archive_path, extract_dir, ) + content_root, pro_wheel_path, pro_bundle_manifest = _resolve_pro_bundle_content(content_root) except Exception as exc: shutil.rmtree(tmp_dir, ignore_errors=True) msg = f"Failed to extract files: {exc}" @@ -2332,6 +2787,24 @@ def _dependency_sync_timeout_message() -> str: yield UpdateProgress(stage="error", message=f"Dependency sync failed: {err}", success=False) return + if pro_wheel_path is not None: + yield UpdateProgress(stage="syncing", message="Installing Flocks Pro component...") + python_path = _venv_python_path(install_root) + install_cmd = [uv_path, "pip", "install", "--python", str(python_path), "--no-deps", str(pro_wheel_path)] + code, _, err = await _run_async( + install_cmd, + cwd=install_root, + timeout=180, + env=sync_env, + ) + if code != 0: + shutil.rmtree(tmp_dir, ignore_errors=True) + await _restore_after_apply_failure() + yield UpdateProgress(stage="error", message=f"Flocks Pro component install failed: {err}", success=False) + return + if pro_bundle_manifest: + _write_pro_bundle_install_marker(pro_bundle_manifest) + if sys.platform == "win32": validation_error = await _validate_windows_restart_runtime(install_root) if validation_error: @@ -2359,6 +2832,7 @@ def _dependency_sync_timeout_message() -> str: # is not left behind half-way through cutover. # ------------------------------------------------------------------ # if not restart: + log.info("updater.apply.done", {"version": latest_tag, "restart": False, "region": profile.region}) yield UpdateProgress( stage="done", message=f"Upgraded to v{latest_tag}", diff --git a/flocks/workspace/manager.py b/flocks/workspace/manager.py index c7efc3f79..df8b19465 100644 --- a/flocks/workspace/manager.py +++ b/flocks/workspace/manager.py @@ -9,7 +9,10 @@ This manager provides a read-only view into data/memory/ for the WebUI. """ +import datetime as dt import os +import re +import shutil from pathlib import Path from typing import Optional @@ -29,7 +32,31 @@ } # Conventional subdirectories (created on init, not enforced) -CONVENTION_DIRS = ["outputs", "knowledge"] +CONVENTION_DIRS = ["outputs", "knowledge", "users", "shared"] +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +} def _get_workspace_dir() -> Path: @@ -75,10 +102,15 @@ def get_instance(cls) -> "WorkspaceManager": # Directory resolution # ------------------------------------------------------------------ # - def get_workspace_dir(self) -> Path: + def get_workspace_dir(self, user_id: Optional[str] = None, *, shared: bool = False) -> Path: if self._workspace_dir is None: self._workspace_dir = _get_workspace_dir() - return self._workspace_dir + root = self._workspace_dir + if shared: + return root / "shared" + if user_id: + return root / "users" / user_id + return root def get_memory_dir(self) -> Path: """Return path to agent-managed memory directory (read-only view).""" @@ -87,6 +119,54 @@ def get_memory_dir(self) -> Path: self._memory_dir = Config.get_data_path() / "memory" return self._memory_dir + @staticmethod + def normalize_username_for_path(username: str) -> str: + """ + Normalize username to a filesystem-safe directory component. + """ + value = (username or "").strip() + value = value.replace("/", "_").replace("\\", "_") + value = re.sub(r"[\x00-\x1f\x7f]+", "_", value) + value = re.sub(r"\s+", "_", value) + value = value.strip(" .") + if not value: + value = "anonymous" + if value.upper() in _WINDOWS_RESERVED_NAMES: + value = f"user_{value}" + return value + + def get_user_workspace_dir(self, username: str) -> Path: + """ + Return per-username workspace root under ``workspace/users/``. + """ + root = self.get_workspace_dir() + return root / "users" / self.normalize_username_for_path(username) + + def get_default_outputs_dir( + self, + *, + username: Optional[str] = None, + today: Optional[dt.date] = None, + include_today: bool = True, + ) -> Path: + """ + Return and create the default outputs directory. + + - OSS default: ``workspace/outputs/`` + - Pro-style override when username provided: + ``workspace/users//outputs/`` + """ + self.ensure_dirs() + if username: + base = self.get_user_workspace_dir(username) / "outputs" + else: + base = self.get_workspace_dir() / "outputs" + if include_today: + day = (today or dt.date.today()).isoformat() + base = base / day + base.mkdir(parents=True, exist_ok=True) + return base + def ensure_dirs(self) -> None: """Create workspace root and conventional subdirectories if absent. @@ -99,9 +179,55 @@ def ensure_dirs(self) -> None: workspace.mkdir(parents=True, exist_ok=True) for name in CONVENTION_DIRS: (workspace / name).mkdir(exist_ok=True) + # shared area conventions + for name in ["outputs", "knowledge"]: + (workspace / "shared" / name).mkdir(parents=True, exist_ok=True) self._dirs_ensured = True log.info("workspace.dirs.ensured", {"path": str(workspace)}) + def migrate_root_workspace_to_user(self, admin_user_id: str, *, dry_run: bool = False) -> dict: + """ + Migrate legacy single-user layout to users/shared layout. + + - ``outputs`` -> ``users//outputs`` + - ``knowledge`` -> ``shared/knowledge`` (team-shared by design) + """ + self.ensure_dirs() + root = self.get_workspace_dir() + user_root = self.get_workspace_dir(admin_user_id) + shared_root = self.get_workspace_dir(shared=True) + + legacy_outputs = root / "outputs" + legacy_knowledge = root / "knowledge" + target_outputs = user_root / "outputs" + target_knowledge = shared_root / "knowledge" + + summary = {"moved_outputs": False, "moved_knowledge": False, "dry_run": dry_run} + + if legacy_outputs.exists(): + summary["moved_outputs"] = True + if not dry_run: + target_outputs.parent.mkdir(parents=True, exist_ok=True) + if target_outputs.exists(): + for child in legacy_outputs.iterdir(): + shutil.move(str(child), str(target_outputs / child.name)) + legacy_outputs.rmdir() + else: + shutil.move(str(legacy_outputs), str(target_outputs)) + + if legacy_knowledge.exists(): + summary["moved_knowledge"] = True + if not dry_run: + target_knowledge.parent.mkdir(parents=True, exist_ok=True) + if target_knowledge.exists(): + for child in legacy_knowledge.iterdir(): + shutil.move(str(child), str(target_knowledge / child.name)) + legacy_knowledge.rmdir() + else: + shutil.move(str(legacy_knowledge), str(target_knowledge)) + + return summary + # ------------------------------------------------------------------ # # Path safety # ------------------------------------------------------------------ # diff --git a/tests/auth/test_auth_facade.py b/tests/auth/test_auth_facade.py new file mode 100644 index 000000000..14d728dd5 --- /dev/null +++ b/tests/auth/test_auth_facade.py @@ -0,0 +1,30 @@ +import pytest + +from flocks.auth.service import AuthService, LocalAuthBackend + + +class DummyBackend(LocalAuthBackend): + @classmethod + async def has_users(cls) -> bool: + return False + + +class IncompleteBackend: + @classmethod + async def has_users(cls) -> bool: + return False + + +@pytest.mark.asyncio +async def test_auth_service_facade_can_swap_backend(): + AuthService.register_backend(DummyBackend) + assert await AuthService.has_users() is False + + AuthService.reset_backend() + assert AuthService.get_backend() is LocalAuthBackend + + +def test_auth_service_rejects_incomplete_backend(): + with pytest.raises(ValueError, match="接口不完整"): + AuthService.register_backend(IncompleteBackend) + assert AuthService.get_backend() is LocalAuthBackend diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index fce9d6d3e..6c3104b78 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -162,6 +162,8 @@ async def fake_perform_update( *, zipball_url: str | None = None, tarball_url: str | None = None, + bundle_sha256: str | None = None, + bundle_format: str | None = None, restart: bool = True, locale: str | None = None, region: str | None = None, @@ -169,6 +171,8 @@ async def fake_perform_update( captured["latest_tag"] = latest_tag captured["zipball_url"] = zipball_url captured["tarball_url"] = tarball_url + captured["bundle_sha256"] = bundle_sha256 + captured["bundle_format"] = bundle_format captured["perform_region"] = region captured["restart"] = restart async for step in _fake_progress(): @@ -194,6 +198,8 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str "latest_tag": "2026.4.2", "zipball_url": "https://example.com/flocks.zip", "tarball_url": "https://example.com/flocks.tar.gz", + "bundle_sha256": None, + "bundle_format": None, "check_region": "cn", "perform_region": "cn", "restart": False, diff --git a/tests/console/test_console_sync_scheduler.py b/tests/console/test_console_sync_scheduler.py new file mode 100644 index 000000000..e92a6a422 --- /dev/null +++ b/tests/console/test_console_sync_scheduler.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import pytest + +from flocks.console import scheduler as scheduler_mod + + +pytestmark = pytest.mark.asyncio + + +async def test_tick_once_runs_heartbeat_and_profile_when_due(monkeypatch: pytest.MonkeyPatch): + storage_values: dict[str, int] = {} + called = {"hb": 0, "refresh": 0, "sync": 0} + + async def _get(key: str): + return storage_values.get(key) + + async def _set(key: str, value, _type: str): + storage_values[key] = int(value) + + async def _heartbeat(): + called["hb"] += 1 + return {"ok": True} + + async def _sync(*, source: str = "scheduled", force: bool = False): + _ = force + assert source == "scheduled" + called["sync"] += 1 + return {"ok": True} + + async def _refresh(): + called["refresh"] += 1 + return {"ok": True} + + monkeypatch.setattr(scheduler_mod.Storage, "get", _get) + monkeypatch.setattr(scheduler_mod.Storage, "set", _set) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "send_heartbeat", _heartbeat) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "refresh_console_session", _refresh) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "sync_node_profile", _sync) + monkeypatch.setattr(scheduler_mod.time, "time", lambda: 1700000000) + + await scheduler_mod.ConsoleSyncScheduler._tick_once() + + assert called["hb"] == 1 + assert called["refresh"] == 1 + assert called["sync"] == 1 + assert storage_values[scheduler_mod._HEARTBEAT_TS_KEY] == 1700000000 + assert storage_values[scheduler_mod._REFRESH_TS_KEY] == 1700000000 + assert storage_values[scheduler_mod._PROFILE_TS_KEY] == 1700000000 + + +async def test_tick_once_skips_when_intervals_not_elapsed(monkeypatch: pytest.MonkeyPatch): + now_ts = 1700001000 + storage_values = { + scheduler_mod._HEARTBEAT_TS_KEY: now_ts - 300, + scheduler_mod._REFRESH_TS_KEY: now_ts - 3600, + scheduler_mod._PROFILE_TS_KEY: now_ts - 3600, + } + called = {"hb": 0, "refresh": 0, "sync": 0} + + async def _get(key: str): + return storage_values.get(key) + + async def _set(key: str, value, _type: str): + storage_values[key] = int(value) + + async def _heartbeat(): + called["hb"] += 1 + return {"ok": True} + + async def _sync(*, source: str = "scheduled", force: bool = False): + _ = (source, force) + called["sync"] += 1 + return {"ok": True} + + async def _refresh(): + called["refresh"] += 1 + return {"ok": True} + + monkeypatch.setattr(scheduler_mod.Storage, "get", _get) + monkeypatch.setattr(scheduler_mod.Storage, "set", _set) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "send_heartbeat", _heartbeat) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "refresh_console_session", _refresh) + monkeypatch.setattr(scheduler_mod.ConsoleLoginService, "sync_node_profile", _sync) + monkeypatch.setattr(scheduler_mod.time, "time", lambda: now_ts) + + await scheduler_mod.ConsoleSyncScheduler._tick_once() + + assert called["hb"] == 0 + assert called["refresh"] == 0 + assert called["sync"] == 0 diff --git a/tests/hooks/test_registry.py b/tests/hooks/test_registry.py index 64b817686..c33764c92 100644 --- a/tests/hooks/test_registry.py +++ b/tests/hooks/test_registry.py @@ -134,6 +134,21 @@ async def handler2(event): assert "command:new" in stats["event_keys"] +def test_register_builtin_hooks_is_idempotent(): + from flocks.hooks.builtin import register_builtin_hooks + + HookRegistry.reset_instance() + try: + register_builtin_hooks() + register_builtin_hooks() + + stats = HookRegistry.get_instance().get_stats() + assert stats["event_keys"]["command:new"]["handler_count"] == 1 + finally: + HookRegistry.get_instance().clear() + HookRegistry.reset_instance() + + @pytest.mark.asyncio async def test_sync_handler(registry): """Test that sync handlers also work""" @@ -148,3 +163,77 @@ def sync_handler(event): await registry.trigger(event) assert results == ["sync"] + + +@pytest.mark.asyncio +async def test_duplicate_handler_registration_is_idempotent(registry): + """Registering the same handler twice should not duplicate side effects.""" + results = [] + + async def handler(event): + results.append(event.action) + + registry.register("command:new", handler) + registry.register("command:new", handler) + + event = create_command_event("new", "test_session") + await registry.trigger(event) + + assert results == ["new"] + assert registry.get_stats()["total_handlers"] == 1 + + +@pytest.mark.asyncio +async def test_named_handler_registration_replaces_previous_handler(registry): + results = [] + + async def old_handler(event): + results.append("old") + + async def new_handler(event): + results.append("new") + + registry.register("command:new", old_handler, {"name": "session-memory"}) + registry.register("command:new", new_handler, {"name": "session-memory"}) + + event = create_command_event("new", "test_session") + await registry.trigger(event) + + assert results == ["new"] + assert registry.get_stats()["total_handlers"] == 1 + + +@pytest.mark.asyncio +async def test_handler_timeout_isolated_by_default(registry): + results = [] + + async def slow_handler(event): + await asyncio.sleep(0.05) + results.append("slow") + + async def success_handler(event): + results.append("success") + + registry.register("command", slow_handler, {"name": "slow", "timeout_seconds": 0.01}) + registry.register("command", success_handler) + + event = create_command_event("test", "test_session") + await registry.trigger(event) + + assert results == ["success"] + + +@pytest.mark.asyncio +async def test_handler_timeout_can_propagate(registry): + async def slow_handler(event): + await asyncio.sleep(0.05) + + registry.register( + "command", + slow_handler, + {"name": "critical-slow", "timeout_seconds": 0.01, "fail_policy": "propagate"}, + ) + + event = create_command_event("test", "test_session") + with pytest.raises(asyncio.TimeoutError): + await registry.trigger(event) diff --git a/tests/server/routes/test_auth_audit_routes.py b/tests/server/routes/test_auth_audit_routes.py new file mode 100644 index 000000000..00f3bf908 --- /dev/null +++ b/tests/server/routes/test_auth_audit_routes.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException, Response + + +pytestmark = pytest.mark.asyncio + + +async def test_login_emits_audit_event(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + async def _login(username: str, _password: str): + return ( + SimpleNamespace( + id="usr_1", + username=username, + role="admin", + status="active", + must_reset_password=False, + created_at=None, + updated_at=None, + last_login_at=None, + ), + "sess_1", + ) + + emitted: list[tuple[str, dict]] = [] + + async def _emit(event_type: str, payload: dict): + emitted.append((event_type, payload)) + + monkeypatch.setattr(auth_routes.AuthService, "login", _login) + monkeypatch.setattr(auth_routes, "_emit_auth_audit", _emit) + monkeypatch.setattr(auth_routes, "should_use_secure_cookie", lambda _request: False) + monkeypatch.setattr(auth_routes, "set_session_cookie", lambda _response, _session_id, secure=False: None) + + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + response = Response() + payload = auth_routes.LoginRequest(username="chenjie", password="Password123!") + await auth_routes.login(payload, response, request) + + assert emitted + assert emitted[0][0] == "account.login" + assert emitted[0][1]["user_id"] == "usr_1" + assert emitted[0][1]["actor_id"] == "chenjie" + assert emitted[0][1]["actor_name"] == "chenjie" + + +async def test_login_failed_emits_audit_event(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + async def _login(_username: str, _password: str): + raise ValueError("用户名或密码错误") + + emitted: list[tuple[str, dict]] = [] + + async def _emit(event_type: str, payload: dict): + emitted.append((event_type, payload)) + + monkeypatch.setattr(auth_routes.AuthService, "login", _login) + monkeypatch.setattr(auth_routes, "_emit_auth_audit", _emit) + + request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1")) + response = Response() + payload = auth_routes.LoginRequest(username="chenjie", password="bad") + with pytest.raises(HTTPException): + await auth_routes.login(payload, response, request) + + assert emitted + assert emitted[0][0] == "account.login_failed" + assert emitted[0][1]["username"] == "chenjie" + + +async def test_logout_emits_audit_event(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + revoked = {"called": False} + emitted: list[tuple[str, dict]] = [] + + async def _revoke(session_id: str): + assert session_id == "sess_1" + revoked["called"] = True + + async def _emit(event_type: str, payload: dict): + emitted.append((event_type, payload)) + + monkeypatch.setattr(auth_routes, "require_user", lambda _request: SimpleNamespace(id="usr_1", username="chenjie", role="admin")) + monkeypatch.setattr(auth_routes.AuthService, "revoke_session", _revoke) + monkeypatch.setattr(auth_routes, "_emit_auth_audit", _emit) + monkeypatch.setattr(auth_routes, "clear_session_cookie", lambda _response: None) + + request = SimpleNamespace( + cookies={"flocks_session": "sess_1"}, + client=SimpleNamespace(host="127.0.0.1"), + ) + response = Response() + result = await auth_routes.logout(response, request) + + assert result["success"] is True + assert revoked["called"] is True + assert emitted + assert emitted[0][0] == "account.logout" + assert emitted[0][1]["user_id"] == "usr_1" + + +async def test_emit_auth_audit_fallback_writes_when_null_sink(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + class _NullSink: + pass + + monkeypatch.setattr("flocks.audit.NullAuditSink", _NullSink, raising=False) + monkeypatch.setattr("flocks.audit.get_sink", lambda: _NullSink) + + written = {"event_type": None, "payload": None} + + class _AuditEvent: + def __init__(self, **kwargs): + written["event_type"] = kwargs.get("event_type") + written["payload"] = kwargs.get("payload") + + class _SqliteSink: + async def write(self, event): + _ = event + return None + + monkeypatch.setattr("flockspro.audit.service.AuditEvent", _AuditEvent, raising=False) + monkeypatch.setattr("flockspro.audit.sinks.SqliteAuditSink", _SqliteSink, raising=False) + + await auth_routes._emit_auth_audit_fallback( + "account.login", + {"user_id": "usr_1", "username": "chenjie", "session_id": "sess_1"}, + ) + + assert written["event_type"] == "account.login" + assert written["payload"]["user_id"] == "usr_1" + + +async def test_emit_auth_audit_fallback_skips_when_non_null_sink(monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + class _CustomSink: + pass + + monkeypatch.setattr("flocks.audit.get_sink", lambda: _CustomSink) + called = {"write": False} + + class _SqliteSink: + async def write(self, event): + _ = event + called["write"] = True + + monkeypatch.setattr("flockspro.audit.sinks.SqliteAuditSink", _SqliteSink, raising=False) + + await auth_routes._emit_auth_audit_fallback( + "account.logout", + {"user_id": "usr_1", "username": "chenjie", "session_id": "sess_1"}, + ) + assert called["write"] is False diff --git a/tests/server/routes/test_auth_console_login_routes.py b/tests/server/routes/test_auth_console_login_routes.py new file mode 100644 index 000000000..b136b53cb --- /dev/null +++ b/tests/server/routes/test_auth_console_login_routes.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi import status +from httpx import AsyncClient + +from flocks.auth.context import AuthUser + + +pytestmark = pytest.mark.asyncio + + +def _mock_admin() -> AuthUser: + return AuthUser( + id="usr_admin", + username="admin", + role="admin", + status="active", + must_reset_password=False, + ) + + +async def test_console_login_start_returns_payload(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + class _Response: + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return { + "console_login_id": "remote_login", + "state": "remote_state", + "passport_login_url": "https://passport.example/login?service=flocks", + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, *args, **kwargs): + return _Response() + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example") + monkeypatch.setattr("flocks.console.login.httpx.AsyncClient", _Client) + + resp = await client.get("/api/auth/console-login/start") + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["console_login_id"] == "remote_login" + parsed = urlparse(payload["passport_login_url"]) + assert parsed.scheme == "https" + assert parsed.netloc == "passport.example" + assert parse_qs(parsed.query) == {"service": ["flocks"]} + + +async def test_console_login_start_requires_console_base_url(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + + resp = await client.get("/api/auth/console-login/start") + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "FLOCKS_CONSOLE_BASE_URL" in resp.text + + +async def test_remote_console_login_keeps_console_passport_url(monkeypatch: pytest.MonkeyPatch): + from flocks.console.login import ConsoleLoginService + + class _Response: + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return { + "console_login_id": "remote_login", + "passport_login_url": "https://passport.example/login?service=flocks", + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, *args, **kwargs): + return _Response() + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example") + monkeypatch.setenv("FLOCKS_PORTAL_BASE_URL", "http://127.0.0.1:3000") + monkeypatch.setattr("flocks.console.login.httpx.AsyncClient", _Client) + + result = await ConsoleLoginService.start_console_login("http://127.0.0.1:5173/flockspro-upgrade/callback") + + parsed = urlparse(result["passport_login_url"]) + assert parsed.scheme == "https" + assert parsed.netloc == "passport.example" + assert parse_qs(parsed.query) == {"service": ["flocks"]} + + +async def test_console_login_finish_maps_value_error_to_400(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + + async def _finish_console_login(*, console_login_id: str, state: str | None = None, passport_uid: str | None = None): + raise ValueError(f"invalid console login id: {console_login_id}") + + monkeypatch.setattr(auth_routes.ConsoleLoginService, "finish_console_login", _finish_console_login) + + resp = await client.post("/api/auth/console-login/finish", json={"console_login_id": "bad_id"}) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "invalid console login id" in resp.text + + +async def test_console_login_finish_success_does_not_return_token(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + + async def _finish_console_login(*, console_login_id: str, state: str | None = None, passport_uid: str | None = None): + assert console_login_id == "login_ok" + assert state == "state_ok" + assert passport_uid is None + return { + "console_login_id": console_login_id, + "console_session_token": "token_abc", + "fingerprint": "fp_1", + "install_id": "inst_1", + "user_display": "test_user", + "updated_at": "2026-01-01T00:00:00+00:00", + } + + monkeypatch.setattr(auth_routes.ConsoleLoginService, "finish_console_login", _finish_console_login) + + resp = await client.post( + "/api/auth/console-login/finish", + json={"console_login_id": "login_ok", "state": "state_ok"}, + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == { + "console_login_id": "login_ok", + "logged_in": True, + "account_name": "test_user", + "updated_at": "2026-01-01T00:00:00+00:00", + } + + +async def test_console_login_finish_requires_console_base_url( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import auth as auth_routes + from flocks.storage.storage import Storage + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + await Storage.delete("console:session") + await Storage.set( + "console:login:login_without_console", + {"console_login_id": "login_without_console", "state": "state_without_console"}, + "json", + ) + + return_resp = await client.post( + "/api/auth/console-login/finish", + json={"console_login_id": "login_without_console", "state": "state_without_console"}, + ) + assert return_resp.status_code == status.HTTP_400_BAD_REQUEST + assert "FLOCKS_CONSOLE_BASE_URL" in return_resp.text + + session_resp = await client.get("/api/auth/console-login/session") + assert session_resp.status_code == status.HTTP_200_OK + assert session_resp.json()["logged_in"] is False + + +async def test_console_login_session_status(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + + async def _get_console_session(): + return { + "console_login_id": "login_ok", + "user_display": "test_user", + "updated_at": "2026-01-01T00:00:00+00:00", + } + + monkeypatch.setattr(auth_routes.ConsoleLoginService, "get_console_session", _get_console_session) + + resp = await client.get("/api/auth/console-login/session") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["logged_in"] is True + assert resp.json()["console_login_id"] == "login_ok" + assert resp.json()["account_name"] == "test_user" + + +async def test_console_login_session_expired_local_token_is_logged_out( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import auth as auth_routes + from flocks.storage.storage import Storage + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + await Storage.set( + "console:session", + { + "console_login_id": "login_expired", + "console_session_token": "cs_expired", + "fingerprint": "fp_1", + "install_id": "inst_1", + "passport_uid": "passport_1", + "expires_at": "2000-01-01T00:00:00+00:00", + }, + "json", + ) + + resp = await client.get("/api/auth/console-login/session") + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["logged_in"] is False + assert await Storage.get("console:session") is None + + +async def test_console_login_logout(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + + called = {"ok": False} + + async def _logout_console_session(): + called["ok"] = True + + monkeypatch.setattr(auth_routes.ConsoleLoginService, "logout_console_session", _logout_console_session) + + resp = await client.post("/api/auth/console-login/logout") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["success"] is True + assert called["ok"] is True + + +async def test_logout_console_session_sends_revoke_body(monkeypatch: pytest.MonkeyPatch): + from flocks.console.login import ConsoleLoginService + from flocks.storage.storage import Storage + + captured: dict = {} + + class _Client: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url: str, **kwargs): + captured["url"] = url + captured["kwargs"] = kwargs + + class _Response: + pass + + return _Response() + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example") + monkeypatch.setattr("flocks.console.login.httpx.AsyncClient", _Client) + await Storage.set("console:session", {"console_session_token": "cs_test"}, "json") + + await ConsoleLoginService.logout_console_session() + + assert captured["url"] == "https://console.example/v1/console-sessions/revoke" + assert captured["kwargs"]["headers"] == {"Authorization": "Bearer cs_test"} + assert captured["kwargs"]["json"] == {"console_session_token": "cs_test"} + assert await Storage.get("console:session") is None + + +async def test_refresh_console_session_extends_local_expiry(monkeypatch: pytest.MonkeyPatch): + from flocks.console.login import ConsoleLoginService + from flocks.storage.storage import Storage + + captured: dict = {} + + class _Response: + status_code = 200 + + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return { + "console_session_token": "cs_test", + "passport_uid": "passport_1", + "user_display": "chenjie", + "user_email": "chenjie@example.com", + "expires_at": "2026-06-10T00:00:00+00:00", + } + + class _Client: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url: str, **kwargs): + captured["url"] = url + captured["kwargs"] = kwargs + return _Response() + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example") + monkeypatch.setattr("flocks.console.login.httpx.AsyncClient", _Client) + await Storage.set( + "console:session", + { + "console_session_token": "cs_test", + "fingerprint": "fp_1", + "install_id": "inst_1", + "expires_at": "2099-05-10T00:00:00+00:00", + }, + "json", + ) + + refreshed = await ConsoleLoginService.refresh_console_session() + + assert captured["url"] == "https://console.example/v1/console-sessions/refresh" + assert captured["kwargs"]["headers"] == {"Authorization": "Bearer cs_test"} + assert captured["kwargs"]["json"] == {"console_session_token": "cs_test"} + assert refreshed["expires_at"] == "2026-06-10T00:00:00+00:00" + assert refreshed["user_display"] == "chenjie" + stored = await Storage.get("console:session") + assert stored["expires_at"] == "2026-06-10T00:00:00+00:00" + + +async def test_console_login_session_without_account_name_treated_logged_out( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import auth as auth_routes + + monkeypatch.setattr(auth_routes, "require_admin", lambda _req: _mock_admin()) + + async def _get_console_session(): + return { + "console_login_id": "login_no_name", + "updated_at": "2026-01-01T00:00:00+00:00", + } + + monkeypatch.setattr(auth_routes.ConsoleLoginService, "get_console_session", _get_console_session) + + resp = await client.get("/api/auth/console-login/session") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["logged_in"] is False + + + diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py new file mode 100644 index 000000000..6e71a1a30 --- /dev/null +++ b/tests/server/routes/test_console_upgrade_routes.py @@ -0,0 +1,894 @@ +from __future__ import annotations + +import json + +import pytest +from fastapi import status +import httpx +from types import ModuleType +from httpx import AsyncClient + +from flocks.auth.context import AuthUser + + +pytestmark = pytest.mark.asyncio + + +def _mock_admin() -> AuthUser: + return AuthUser( + id="usr_admin", + username="admin", + role="admin", + status="active", + must_reset_password=False, + ) + + +async def _set_bound_console_session() -> None: + from flocks.storage.storage import Storage + + await Storage.set( + "console:session", + { + "console_login_id": "login_ok", + "console_session_token": "token_abc", + "fingerprint": "fp_1", + "install_id": "inst_1", + "passport_uid": "pass_1", + "user_display": "alice", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + +async def test_upgrade_request_lifecycle_local_storage(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + + create_resp = await client.post( + "/api/console/upgrade-requests", + json={ + "product": "Flocks Pro", + "license_type": "trial_30d", + "company": "acme", + "applicant_name": "alice", + "applicant_email": "alice@example.com", + "applicant_phone": "13800000000", + "notes": "need flockspro", + }, + ) + assert create_resp.status_code == status.HTTP_200_OK + created = create_resp.json() + request_id = created["request_id"] + assert created["status"] == "pending" + assert created["reason"] == "need flockspro" + assert created["details"]["company"] == "acme" + assert created["details"]["applicant_name"] == "alice" + assert created["details"]["request_kind"] == "new" + assert created["details"]["console_account_name"] == "alice" + + list_resp = await client.get("/api/console/upgrade-requests") + assert list_resp.status_code == status.HTTP_200_OK + assert any(item["request_id"] == request_id for item in list_resp.json()) + + get_resp = await client.get(f"/api/console/upgrade-requests/{request_id}") + assert get_resp.status_code == status.HTTP_200_OK + assert get_resp.json()["request_id"] == request_id + + refresh_resp = await client.post(f"/api/console/upgrade-requests/{request_id}/refresh") + assert refresh_resp.status_code == status.HTTP_200_OK + assert refresh_resp.json()["status"] == "pending" + + cancel_resp = await client.post(f"/api/console/upgrade-requests/{request_id}/cancel") + assert cancel_resp.status_code == status.HTTP_200_OK + assert cancel_resp.json()["status"] == "cancelled" + + +async def test_upgrade_request_missing_returns_404(client: AsyncClient, monkeypatch: pytest.MonkeyPatch): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + + get_resp = await client.get("/api/console/upgrade-requests/not_found") + assert get_resp.status_code == status.HTTP_404_NOT_FOUND + + refresh_resp = await client.post("/api/console/upgrade-requests/not_found/refresh") + assert refresh_resp.status_code == status.HTTP_404_NOT_FOUND + + cancel_resp = await client.post("/api/console/upgrade-requests/not_found/cancel") + assert cancel_resp.status_code == status.HTTP_404_NOT_FOUND + + +async def test_create_upgrade_request_requires_console_login( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await Storage.delete("console:session") + + resp = await client.post( + "/api/console/upgrade-requests", + json={ + "product": "Flocks Pro", + "license_type": "trial_30d", + "company": "acme", + "applicant_name": "alice", + }, + ) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "云账号未登录" in resp.text + + +async def test_fallback_license_state_does_not_mark_license_activated( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(console_routes, "_machine_fingerprint", lambda install_id: f"fp_{install_id}", raising=False) + + record = { + "request_id": "req_fallback", + "license_id": "lic_fallback", + "activate_key": "signed.token.value", + "details": {"activation_receipt": "signed.receipt.value"}, + } + + console_routes._fallback_write_pro_license_state(record, "signed.token.value", "missing license public key") + + state = json.loads((tmp_path / "flockspro" / "license.json").read_text(encoding="utf-8")) + assert state["key"] == "signed.token.value" + assert state["payload"] == {} + assert state["activation_receipt"] == "signed.receipt.value" + assert "license_activated_at" not in record["details"] + assert record["details"]["license_activate_fallback_saved_at"] + + +async def test_pro_package_status_reports_installed_marker( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: { + "installed_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", + }, + ) + + resp = await client.get("/api/console/pro-package-status") + + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["installed"] is True + assert payload["flockspro_component_version"] == "1.2.3" + + +async def test_sync_console_license_revocations_without_pro_package_only_syncs_console_records( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: False) + await _set_bound_console_session() + await Storage.set("console:upgrade_request_ids", ["req_install"], "json") + await Storage.set( + "console:upgrade_request:req_install", + { + "request_id": "req_install", + "status": "approved", + "activate_key": "install_token", + "license_id": "lic_install", + "license_status": "trial", + "details": {"console_account_name": "alice", "license_id": "lic_install"}, + "created_at": "2026-05-15T10:00:00+00:00", + "updated_at": "2026-05-15T10:00:00+00:00", + }, + "json", + ) + + class _FakeResponse: + def __init__(self, payload: dict, status_code: int = status.HTTP_200_OK) -> None: + self._payload = payload + self.status_code = status_code + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + return None + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, headers=None): + assert headers == {"Authorization": "Bearer token_abc"} + if url == "http://console.local/v1/licenses/revocations": + return _FakeResponse({"revoked_license_ids": ["lic_revoked"]}) + if url == "http://console.local/v1/licenses/lic_install": + return _FakeResponse( + { + "license_id": "lic_install", + "license_status": "trial", + "effective_status": "trial", + "effective_expires_at": 1781417933, + "effective_max_admins": 3, + "effective_max_members": 9, + } + ) + raise AssertionError(url) + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + + resp = await client.post("/api/console/licenses/sync-revocations") + + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["imported"] is False + assert payload["inactive_reason"] == "flockspro_not_installed" + assert payload["synced_license_ids"] == ["lic_install"] + stored = await Storage.get("console:upgrade_request:req_install") + assert stored["max_admins"] == 3 + assert stored["max_members"] == 9 + + +async def test_sync_console_license_revocations_imports_into_checker( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + + class _FakeResponse: + def json(self) -> dict: + return {"revoked_license_ids": ["lic_revoked"]} + + def raise_for_status(self) -> None: + return None + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, headers=None): + assert url == "http://console.local/v1/licenses/revocations" + assert headers == {"Authorization": "Bearer token_abc"} + return _FakeResponse() + + imported: list[str] = [] + + class _Checker: + def import_revocation(self, revoked_license_ids): + imported.extend(revoked_license_ids) + + runtime_module = ModuleType("flockspro.license.runtime") + flockspro_module = ModuleType("flockspro") + license_module = ModuleType("flockspro.license") + runtime_module.get_license_checker = lambda: _Checker() # type: ignore[attr-defined] + runtime_module.get_pro_capability_status = lambda: {"pro_enabled": False, "active": False} # type: ignore[attr-defined] + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + 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) + + resp = await client.post("/api/console/licenses/sync-revocations") + + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["imported"] is True + assert imported == ["lic_revoked"] + + +async def test_sync_console_license_revocations_switches_from_revoked_runtime_license( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + await Storage.set("console:upgrade_request_ids", ["req_old", "req_new", "req_later_revoked"], "json") + await Storage.set( + "console:upgrade_request:req_old", + { + "request_id": "req_old", + "status": "activated", + "activate_key": "old_token", + "license_id": "lic_old", + "license_status": "trial", + "details": {"license_id": "lic_old"}, + "created_at": "2026-05-15T10:00:00+00:00", + "updated_at": "2026-05-15T10:00:00+00:00", + }, + "json", + ) + await Storage.set( + "console:upgrade_request:req_new", + { + "request_id": "req_new", + "status": "approved", + "activate_key": "new_token", + "license_id": "lic_new", + "license_status": "trial", + "details": {"license_id": "lic_new"}, + "created_at": "2026-05-15T11:00:00+00:00", + "updated_at": "2026-05-15T11:00:00+00:00", + }, + "json", + ) + await Storage.set( + "console:upgrade_request:req_later_revoked", + { + "request_id": "req_later_revoked", + "status": "approved", + "activate_key": "later_revoked_token", + "license_id": "lic_later_revoked", + "license_status": "revoked", + "details": {"license_id": "lic_later_revoked"}, + "created_at": "2026-05-15T12:00:00+00:00", + "updated_at": "2026-05-15T12:00:00+00:00", + }, + "json", + ) + + class _FakeResponse: + def __init__(self, payload: dict, status_code: int = status.HTTP_200_OK) -> None: + self._payload = payload + self.status_code = status_code + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + return None + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, headers=None): + if url == "http://console.local/v1/licenses/revocations": + return _FakeResponse({"revoked_license_ids": ["lic_old"]}) + if url == "http://console.local/v1/licenses/lic_old": + return _FakeResponse( + { + "license_id": "lic_old", + "revoked": True, + "license_status": "revoked", + "effective_status": "revoked", + "effective_expires_at": 1778825933, + } + ) + if url == "http://console.local/v1/licenses/lic_new": + return _FakeResponse( + { + "license_id": "lic_new", + "license_status": "trial", + "effective_status": "trial", + "effective_expires_at": 1781417933, + "effective_max_admins": 2, + "effective_max_members": 6, + } + ) + if url == "http://console.local/v1/licenses/lic_later_revoked": + return _FakeResponse( + { + "license_id": "lic_later_revoked", + "revoked": True, + "license_status": "revoked", + "effective_status": "revoked", + "effective_expires_at": 1781417933, + } + ) + raise AssertionError(url) + + class _Checker: + def __init__(self) -> None: + self.license_id = "lic_old" + self.active = False + self.activated_tokens: list[str] = [] + self.refreshed = False + + def import_revocation(self, revoked_license_ids): + assert revoked_license_ids == ["lic_old"] + + def status(self): + return { + "license_id": self.license_id, + "license_status": "revoked" if not self.active else "trial", + "active": self.active, + } + + def activate(self, token: str): + self.activated_tokens.append(token) + self.license_id = "lic_new" + self.active = True + return self.status() + + async def refresh(self): + self.refreshed = True + return self.status() + + checker = _Checker() + runtime_module = ModuleType("flockspro.license.runtime") + flockspro_module = ModuleType("flockspro") + license_module = ModuleType("flockspro.license") + runtime_module.get_license_checker = lambda: checker # type: ignore[attr-defined] + runtime_module.get_pro_capability_status = lambda: { # type: ignore[attr-defined] + **checker.status(), + "pro_enabled": checker.active, + } + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + 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) + + resp = await client.post("/api/console/licenses/sync-revocations") + + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["activated_license_id"] == "lic_new" + assert payload["refreshed_license_id"] == "lic_new" + assert checker.activated_tokens == ["new_token"] + assert checker.refreshed is True + + +async def test_create_upgrade_request_does_not_link_previous_request_when_omitted( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + + class _FakeResponse: + status_code = status.HTTP_200_OK + + def json(self) -> dict: + return { + "request_id": "req_new_001", + "status": "pending", + "reason": None, + "suggestion": None, + "activate_key": None, + "manifest_url": None, + "form_data": { + "product": "Flocks Pro", + "license_type": "trial_30d", + "company": "acme", + "applicant_name": "alice", + }, + } + + def raise_for_status(self) -> None: + return None + + class _FakeClient: + 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): + assert url == "http://console.local/v1/upgrade-requests" + assert "previous_request_id" not in json + assert json["console_login_id"] == "login_ok" + assert json["fingerprint"] == "fp_1" + assert json["install_id"] == "inst_1" + assert json["passport_uid"] == "pass_1" + assert json["form_data"]["request_kind"] == "trial_extension" + assert json["form_data"]["console_account_name"] == "alice" + assert headers == {"Authorization": "Bearer token_abc"} + return _FakeResponse() + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + + resp = await client.post( + "/api/console/upgrade-requests", + json={ + "product": "Flocks Pro", + "license_type": "trial_30d", + "request_kind": "trial_extension", + "company": "acme", + "applicant_name": "alice", + }, + ) + + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["request_id"] == "req_new_001" + assert payload["previous_request_id"] is None + + +async def test_create_upgrade_request_maps_console_failure_to_502( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + + class _FakeResponse: + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + text = '{"message":"console unavailable"}' + + def json(self) -> dict: + return {"message": "console unavailable"} + + def raise_for_status(self) -> None: + request = httpx.Request("POST", "http://console.local/v1/upgrade-requests") + response = httpx.Response(self.status_code, request=request, json=self.json()) + raise httpx.HTTPStatusError("console call failed", request=request, response=response) + + class _FakeClient: + 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): + assert url == "http://console.local/v1/upgrade-requests" + return _FakeResponse() + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + + resp = await client.post( + "/api/console/upgrade-requests", + json={ + "product": "Flocks Pro", + "license_type": "trial_30d", + "company": "acme", + "applicant_name": "alice", + }, + ) + + assert resp.status_code == status.HTTP_502_BAD_GATEWAY + assert "console unavailable" in resp.text + + +async def test_cancel_approved_request_falls_back_to_local_cancel_when_console_rejects( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "http://console.local") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + await _set_bound_console_session() + + request_id = "req_approved_001" + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "approved", + "previous_request_id": None, + "reason": None, + "suggestion": "ready to upgrade", + "activate_key": None, + "manifest_url": None, + "details": {"company": "acme"}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + class _FakeResponse: + def __init__(self, status_code: int, payload: dict): + self.status_code = status_code + self._payload = payload + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + request = httpx.Request("GET", "http://console.local/v1/upgrade-requests") + response = httpx.Response(self.status_code, request=request, json=self._payload) + raise httpx.HTTPStatusError("console call failed", request=request, response=response) + + class _FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, url, headers=None): + assert url == f"http://console.local/v1/upgrade-requests/{request_id}/withdraw" + assert headers == {"Authorization": "Bearer token_abc"} + return _FakeResponse(status.HTTP_400_BAD_REQUEST, {"message": "cannot withdraw approved"}) + + async def get(self, url, headers=None): + assert url == f"http://console.local/v1/upgrade-requests/{request_id}" + assert headers == {"Authorization": "Bearer token_abc"} + return _FakeResponse( + status.HTTP_200_OK, + { + "request_id": request_id, + "status": "approved", + "suggestion": "ready to upgrade", + "form_data": {"company": "acme"}, + }, + ) + + monkeypatch.setattr(console_routes.httpx, "AsyncClient", lambda timeout=10: _FakeClient()) + + resp = await client.post(f"/api/console/upgrade-requests/{request_id}/cancel") + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["status"] == "cancelled" + + +async def test_refresh_approved_request_does_not_auto_activate_install( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + request_id = "req_auto_001" + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "approved", + "previous_request_id": None, + "reason": None, + "suggestion": None, + "activate_key": "key_auto", + "manifest_url": "https://manifest.example.com/v1/manifest/latest", + "details": {"company": "acme"}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + resp = await client.post(f"/api/console/upgrade-requests/{request_id}/refresh") + assert resp.status_code == status.HTTP_200_OK + payload = resp.json() + assert payload["status"] == "approved" + assert "auto_install_task_scheduled_at" not in payload["details"] + + +async def test_start_approved_request_streams_upgrade_and_marks_activated( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.storage.storage import Storage + from flocks.updater.models import UpdateProgress + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "") + monkeypatch.setattr(console_routes, "require_admin", lambda _req: _mock_admin()) + request_id = "req_start_001" + await Storage.set( + f"console:upgrade_request:{request_id}", + { + "request_id": request_id, + "status": "approved", + "previous_request_id": None, + "reason": None, + "suggestion": None, + "activate_key": "key_start", + "manifest_url": "https://manifest.example.com/v1/manifest/latest", + "details": {"company": "acme"}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + }, + "json", + ) + + async def _fake_perform_pro_bundle_install(*args, **kwargs): + assert args == () + assert kwargs["restart"] is True + yield UpdateProgress(stage="fetching", message="Downloading Flocks Pro bundle...", success=None) + yield UpdateProgress(stage="restarting", message="Restarting service...", success=None) + + async def _noop(_record: dict): + return None + + reported: list[tuple[str, str | None]] = [] + + async def _fake_report(record: dict, *, install_result: str, error_message: str | None = None): + reported.append((install_result, error_message)) + + monkeypatch.setattr(console_routes, "perform_pro_bundle_install", _fake_perform_pro_bundle_install) + monkeypatch.setattr(console_routes, "_maybe_activate_pro_license", _noop) + monkeypatch.setattr(console_routes, "_maybe_refresh_pro_license", _noop) + monkeypatch.setattr(console_routes, "_report_pro_bundle_installation", _fake_report) + monkeypatch.setattr(console_routes, "_mark_console_upgrade_activated", _noop) + monkeypatch.setattr(console_routes, "_get_pro_capability_status", lambda: {"pro_enabled": True, "active": True}) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: {"installed_version": "v2026.5.9"}, + ) + + resp = await client.post(f"/api/console/upgrade-requests/{request_id}/start") + assert resp.status_code == status.HTTP_200_OK + assert "Downloading Flocks Pro bundle" in resp.text + assert "Restarting service" in resp.text + + stored = await Storage.get(f"console:upgrade_request:{request_id}") + assert stored["status"] == "activated" + assert stored["details"]["auto_install_result"] == "restarting" + assert stored["details"]["auto_install_version"] == "v2026.5.9" + assert reported == [("success", None)] + + +async def test_auto_activate_reports_already_latest_install( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + reported: list[tuple[str, str | None]] = [] + + async def _fake_report(record: dict, *, install_result: str, error_message: str | None = None): + reported.append((install_result, error_message)) + + async def _noop(_record: dict): + return None + + monkeypatch.setattr(console_routes, "_maybe_activate_pro_license", _noop) + monkeypatch.setattr(console_routes, "_maybe_refresh_pro_license", _noop) + monkeypatch.setattr(console_routes, "_report_pro_bundle_installation", _fake_report) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(console_routes, "_get_pro_capability_status", lambda: {"pro_enabled": True, "active": True}) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: {"installed_version": "v2026.5.9"}, + ) + + record = { + "request_id": "req_auto_002", + "status": "approved", + "activate_key": "key_auto", + "details": {}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + } + + 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 reported == [("success", None)] + + +async def test_auto_activate_does_not_mark_activated_when_license_inactive( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + + async def _fake_report(record: dict, *, install_result: str, error_message: str | None = None): + return None + + async def _noop(_record: dict): + return None + + monkeypatch.setattr(console_routes, "_maybe_activate_pro_license", _noop) + monkeypatch.setattr(console_routes, "_maybe_refresh_pro_license", _noop) + monkeypatch.setattr(console_routes, "_report_pro_bundle_installation", _fake_report) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr( + console_routes, + "_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"}) + + record = { + "request_id": "req_auto_inactive", + "status": "approved", + "activate_key": "key_auto", + "details": {}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + } + + payload = await console_routes._maybe_auto_activate_upgrade(record) + assert payload["status"] == "approved" + assert payload["details"]["auto_install_result"] == "license_inactive" + assert payload["details"]["runtime_license_inactive_reason"] == "expired" + + +async def test_auto_activate_installs_pro_bundle_when_core_version_is_latest( + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import console_upgrade as console_routes + from flocks.updater.models import UpdateProgress + + installed = False + + async def _fake_perform_pro_bundle_install(*args, **kwargs): + nonlocal installed + assert args == () + assert kwargs["restart"] is False + yield UpdateProgress(stage="syncing", message="Installing Flocks Pro component...", success=None) + installed = True + yield UpdateProgress(stage="done", message="Flocks Pro component installed from v2026.5.9", success=True) + + async def _fake_report(record: dict, *, install_result: str, error_message: str | None = None): + return None + + async def _noop(_record: dict): + return None + + monkeypatch.setattr(console_routes, "perform_pro_bundle_install", _fake_perform_pro_bundle_install) + monkeypatch.setattr(console_routes, "_maybe_activate_pro_license", _noop) + monkeypatch.setattr(console_routes, "_maybe_refresh_pro_license", _noop) + monkeypatch.setattr(console_routes, "_report_pro_bundle_installation", _fake_report) + monkeypatch.setattr(console_routes, "_is_pro_component_installed", lambda: installed) + monkeypatch.setattr(console_routes, "_get_pro_capability_status", lambda: {"pro_enabled": True, "active": True}) + monkeypatch.setattr( + console_routes, + "_read_pro_bundle_install_marker", + lambda: {"installed_version": "v2026.5.9"} if installed else {}, + ) + + record = { + "request_id": "req_auto_003", + "status": "approved", + "activate_key": "key_auto", + "details": {}, + "created_at": "2026-05-08T08:00:00+00:00", + "updated_at": "2026-05-08T08:00:00+00:00", + } + + 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" + diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 0865fabd8..f633fe30b 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -184,7 +184,7 @@ async def test_send_message_noReply(self, client: AsyncClient, session_id: str): class TestSessionDeletePermissions: @pytest.mark.asyncio - async def test_owner_and_admin_can_delete_but_not_other_member( + async def test_only_owner_can_delete( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -208,8 +208,68 @@ async def test_owner_and_admin_can_delete_but_not_other_member( assert forbidden.status_code == status.HTTP_403_FORBIDDEN monkeypatch.setattr(session_routes, "require_user", lambda _request: admin) - admin_ok = await client.delete(f"/api/session/{session.id}") - assert admin_ok.status_code == status.HTTP_200_OK + admin_forbidden = await client.delete(f"/api/session/{session.id}") + assert admin_forbidden.status_code == status.HTTP_403_FORBIDDEN + + monkeypatch.setattr(session_routes, "require_user", lambda _request: owner) + owner_ok = await client.delete(f"/api/session/{session.id}") + assert owner_ok.status_code == status.HTTP_200_OK + + +class TestSessionLocalSharing: + @pytest.mark.asyncio + async def test_owner_can_share_and_unshare_session( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + + owner = AuthUser(id="usr_owner", username="owner", role="member", status="active") + monkeypatch.setattr(session_routes, "require_user", lambda _request: owner) + create_resp = await client.post("/api/session", json={"title": "share-session"}) + assert create_resp.status_code == status.HTTP_200_OK + session_id = create_resp.json()["id"] + + share_resp = await client.post(f"/api/session/{session_id}/share-local") + assert share_resp.status_code == status.HTTP_200_OK + assert share_resp.json()["isShared"] is True + + unshare_resp = await client.post(f"/api/session/{session_id}/unshare-local") + assert unshare_resp.status_code == status.HTTP_200_OK + assert unshare_resp.json()["isShared"] is False + + @pytest.mark.asyncio + async def test_non_owner_cannot_change_share_or_continue_session( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + + owner = AuthUser(id="usr_owner", username="owner", role="member", status="active") + viewer = AuthUser(id="usr_viewer", username="viewer", role="member", status="active") + + monkeypatch.setattr(session_routes, "require_user", lambda _request: owner) + create_resp = await client.post("/api/session", json={"title": "share-session-2"}) + assert create_resp.status_code == status.HTTP_200_OK + session_id = create_resp.json()["id"] + owner_share_resp = await client.post(f"/api/session/{session_id}/share-local") + assert owner_share_resp.status_code == status.HTTP_200_OK + + monkeypatch.setattr(session_routes, "require_user", lambda _request: viewer) + + share_resp = await client.post(f"/api/session/{session_id}/share-local") + assert share_resp.status_code in (status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND) + + unshare_resp = await client.post(f"/api/session/{session_id}/unshare-local") + assert unshare_resp.status_code == status.HTTP_403_FORBIDDEN + + prompt_resp = await client.post( + f"/api/session/{session_id}/prompt_async", + json={"parts": [{"type": "text", "text": "blocked"}]}, + ) + assert prompt_resp.status_code == status.HTTP_403_FORBIDDEN class TestSessionMessagesRemaining: diff --git a/tests/server/test_http_middleware_hooks.py b/tests/server/test_http_middleware_hooks.py new file mode 100644 index 000000000..b342bbca7 --- /dev/null +++ b/tests/server/test_http_middleware_hooks.py @@ -0,0 +1,73 @@ +import asyncio + +import pytest + +from flocks.server import app as server_app + + +@pytest.fixture(autouse=True) +def restore_http_hooks(): + original = list(server_app._http_middleware_hooks) + server_app._http_middleware_hooks.clear() + yield + server_app._http_middleware_hooks[:] = original + + +@pytest.mark.asyncio +async def test_non_critical_http_hook_failure_is_isolated(): + called: list[str] = [] + + async def failing_hook(request, context): + called.append("failing") + raise RuntimeError("boom") + + async def success_hook(request, context): + called.append(context["stage"]) + + server_app.register_http_middleware(failing_hook, name="failing") + server_app.register_http_middleware(success_hook, name="success") + + await server_app._run_http_middleware_hooks(object(), {"stage": "before_auth"}) + + assert called == ["failing", "before_auth"] + + +@pytest.mark.asyncio +async def test_critical_http_hook_failure_propagates(): + async def failing_hook(request, context): + raise RuntimeError("critical boom") + + server_app.register_http_middleware(failing_hook, name="critical", critical=True) + + with pytest.raises(RuntimeError, match="critical boom"): + await server_app._run_http_middleware_hooks(object(), {"stage": "before_auth"}) + + +@pytest.mark.asyncio +async def test_http_hook_timeout_can_propagate(): + async def slow_hook(request, context): + await asyncio.sleep(0.05) + + server_app.register_http_middleware( + slow_hook, + name="critical-slow", + timeout_seconds=0.01, + fail_policy="propagate", + ) + + with pytest.raises(asyncio.TimeoutError): + await server_app._run_http_middleware_hooks(object(), {"stage": "before_auth"}) + + +def test_http_hook_registration_replaces_by_name(): + async def first_hook(request, context): + return None + + async def second_hook(request, context): + return None + + server_app.register_http_middleware(first_hook, name="same") + server_app.register_http_middleware(second_hook, name="same") + + assert len(server_app._http_middleware_hooks) == 1 + assert server_app._http_middleware_hooks[0].hook is second_hook diff --git a/tests/session/test_runner_llm_hooks.py b/tests/session/test_runner_llm_hooks.py new file mode 100644 index 000000000..de691d02f --- /dev/null +++ b/tests/session/test_runner_llm_hooks.py @@ -0,0 +1,316 @@ +"""Tests for LLM lifecycle hooks in SessionRunner and HookPipeline.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import flocks.session.runner as runner_mod +from flocks.hooks.pipeline import HookBase, HookPipeline +from flocks.provider.provider import ChatMessage +from flocks.session.runner import SessionRunner +from flocks.session.session import SessionInfo + + +def _make_session(session_id: str = "ses_runner_llm_hooks") -> SessionInfo: + return SessionInfo.model_construct( + id=session_id, + slug="test", + project_id="proj_runner", + directory="/tmp", + title="Runner Hook Test", + ) + + +def _make_runner(session_id: str = "ses_runner_llm_hooks") -> SessionRunner: + return SessionRunner( + session=_make_session(session_id), + provider_id="anthropic", + model_id="claude-sonnet", + ) + + +class _FakeProcessor: + def __init__(self, **_: object): + self._text_parts: list[str] = [] + self._reasoning_parts: list[str] = [] + self.finish_reason = "stop" + self.tool_calls = {} + self._langfuse_generation = None + + async def process_event(self, event) -> None: + event_name = type(event).__name__ + if event_name == "TextDeltaEvent": + self._text_parts.append(event.text) + elif event_name == "ReasoningDeltaEvent": + self._reasoning_parts.append(event.text) + elif event_name == "FinishEvent": + self.finish_reason = event.finish_reason + + def get_text_content(self) -> str: + return "".join(self._text_parts) + + def get_reasoning_content(self) -> str: + return "".join(self._reasoning_parts) + + def get_finish_reason(self): + return self.finish_reason + + +class _FakeToolAccumulator: + def __init__(self, processor): + self.processor = processor + + async def feed_chunk(self, tool_call) -> None: + return None + + async def flush_remaining(self, finish_reason) -> None: + return None + + +@pytest.mark.asyncio +async def test_hook_pipeline_runs_llm_stages(): + seen: list[tuple[str, str]] = [] + + class _RecordingHook(HookBase): + async def llm_before(self, ctx) -> None: + seen.append((ctx.stage, ctx.input["request_id"])) + + async def llm_after(self, ctx) -> None: + seen.append((ctx.stage, ctx.output["status"])) + + HookPipeline.register("test-llm-stage-hook", _RecordingHook()) + try: + await HookPipeline.run_llm_before({"request_id": "req-1"}) + await HookPipeline.run_llm_after({"request_id": "req-1"}, {"status": "ok"}) + finally: + HookPipeline.unregister("test-llm-stage-hook") + + assert seen == [ + ("llm.call.before", "req-1"), + ("llm.call.after", "ok"), + ] + + +@pytest.mark.asyncio +async def test_hook_pipeline_timeout_isolated_by_default(): + seen: list[str] = [] + + class _SlowHook(HookBase): + async def llm_before(self, ctx) -> None: + await asyncio.sleep(0.05) + seen.append("slow") + + class _FastHook(HookBase): + async def llm_before(self, ctx) -> None: + seen.append("fast") + + HookPipeline.register("test-slow-hook", _SlowHook(), timeout_seconds=0.01) + HookPipeline.register("test-fast-hook", _FastHook()) + try: + await HookPipeline.run_llm_before({"request_id": "req-timeout"}) + finally: + HookPipeline.unregister("test-slow-hook") + HookPipeline.unregister("test-fast-hook") + + assert seen == ["fast"] + + +@pytest.mark.asyncio +async def test_hook_pipeline_timeout_can_propagate(): + class _SlowHook(HookBase): + async def llm_before(self, ctx) -> None: + await asyncio.sleep(0.05) + + HookPipeline.register( + "test-critical-slow-hook", + _SlowHook(), + timeout_seconds=0.01, + fail_policy="propagate", + ) + try: + with pytest.raises(asyncio.TimeoutError): + await HookPipeline.run_llm_before({"request_id": "req-timeout"}) + finally: + HookPipeline.unregister("test-critical-slow-hook") + + +@pytest.mark.asyncio +async def test_call_llm_emits_hooks_on_success(monkeypatch: pytest.MonkeyPatch): + runner = _make_runner("ses_runner_llm_hooks_success") + assistant_msg = SimpleNamespace(id="msg_assistant_success") + agent = SimpleNamespace(name="rex") + usage = {"prompt_tokens": 7, "completion_tokens": 11, "total_tokens": 18} + order: list[str] = [] + + async def _before(payload): + order.append("before") + assert payload["request"]["toolCount"] == 1 + assert payload["request"]["providerToolsEnabled"] is True + + async def _after(payload, result): + order.append("after") + assert payload["sessionID"] == runner.session.id + assert result["action"] == "stop" + assert result["finishReason"] == "stop" + assert result["contentLength"] == len("hello") + assert result["reasoningLength"] == len("thinking") + assert result["toolCallCount"] == 0 + assert result["usage"] == usage + assert result["chunkCounts"] == {"total": 1, "reasoning": 1, "text": 1, "tool": 0} + + monkeypatch.setattr(runner_mod, "StreamProcessor", _FakeProcessor) + monkeypatch.setattr( + runner_mod.HookPipeline, + "run_llm_before", + AsyncMock(side_effect=_before), + ) + monkeypatch.setattr( + runner_mod.HookPipeline, + "run_llm_after", + AsyncMock(side_effect=_after), + ) + monkeypatch.setattr( + runner_mod.SessionRunner, + "_end_observability", + staticmethod(lambda *args, **kwargs: None), + ) + monkeypatch.setattr( + "flocks.provider.options.build_provider_options", + lambda provider_id, model_id: {"temperature": 0.2}, + ) + monkeypatch.setattr( + "flocks.session.streaming.tool_accumulator.ToolCallAccumulator", + _FakeToolAccumulator, + ) + monkeypatch.setattr(runner_mod.Message, "update", AsyncMock(return_value=None)) + monkeypatch.setattr( + runner_mod, + "trace_scope", + lambda **kwargs: SimpleNamespace(observation=None), + ) + monkeypatch.setattr( + runner_mod, + "generation_scope", + lambda **kwargs: SimpleNamespace(observation=None), + ) + + class _Provider: + def chat_stream(self, **kwargs): + assert kwargs["model_id"] == runner.model_id + assert kwargs["session_id"] == runner.session.id + + async def _gen(): + order.append("provider") + yield SimpleNamespace( + delta="hello", + reasoning="thinking", + tool_calls=None, + event_type=None, + finish_reason="stop", + usage=usage, + ) + + return _gen() + + result = await runner._call_llm( + provider=_Provider(), + messages=[ChatMessage(role="user", content="hello from user")], + tools=[ + { + "type": "function", + "function": { + "name": "search_docs", + "description": "Search docs", + "parameters": {"type": "object"}, + }, + } + ], + agent=agent, + assistant_msg=assistant_msg, + ) + + assert result.action == "stop" + assert result.content == "hello" + assert result.usage == usage + assert order == ["before", "provider", "after"] + + +@pytest.mark.asyncio +async def test_call_llm_emits_after_hook_on_error(monkeypatch: pytest.MonkeyPatch): + runner = _make_runner("ses_runner_llm_hooks_error") + assistant_msg = SimpleNamespace(id="msg_assistant_error") + agent = SimpleNamespace(name="rex") + order: list[str] = [] + + async def _before(payload): + order.append("before") + assert payload["request"]["messageCount"] == 1 + + async def _after(payload, result): + order.append("after") + assert payload["messageID"] == assistant_msg.id + assert result["chunkCounts"] == {"total": 0, "reasoning": 0, "text": 0, "tool": 0} + assert result["error"]["type"] == "RuntimeError" + assert "provider boom" in result["error"]["message"] + + monkeypatch.setattr(runner_mod, "StreamProcessor", _FakeProcessor) + monkeypatch.setattr( + runner_mod.HookPipeline, + "run_llm_before", + AsyncMock(side_effect=_before), + ) + monkeypatch.setattr( + runner_mod.HookPipeline, + "run_llm_after", + AsyncMock(side_effect=_after), + ) + monkeypatch.setattr( + runner_mod.SessionRunner, + "_end_observability", + staticmethod(lambda *args, **kwargs: None), + ) + monkeypatch.setattr( + "flocks.provider.options.build_provider_options", + lambda provider_id, model_id: {}, + ) + monkeypatch.setattr( + "flocks.session.streaming.tool_accumulator.ToolCallAccumulator", + _FakeToolAccumulator, + ) + monkeypatch.setattr(runner_mod.Message, "update", AsyncMock(return_value=None)) + monkeypatch.setattr( + runner_mod, + "trace_scope", + lambda **kwargs: SimpleNamespace(observation=None), + ) + monkeypatch.setattr( + runner_mod, + "generation_scope", + lambda **kwargs: SimpleNamespace(observation=None), + ) + + class _Provider: + def chat_stream(self, **kwargs): + assert kwargs["model_id"] == runner.model_id + + async def _gen(): + order.append("provider") + raise RuntimeError("provider boom") + yield # pragma: no cover + + return _gen() + + with pytest.raises(RuntimeError, match="provider boom"): + await runner._call_llm( + provider=_Provider(), + messages=[ChatMessage(role="user", content="hello from user")], + tools=[], + agent=agent, + assistant_msg=assistant_msg, + ) + + assert order == ["before", "provider", "after"] diff --git a/tests/session/test_session_policy.py b/tests/session/test_session_policy.py index 03fa63d13..180477abf 100644 --- a/tests/session/test_session_policy.py +++ b/tests/session/test_session_policy.py @@ -35,10 +35,10 @@ def test_is_owner_by_username_fallback(): assert SessionPolicy.is_owner(session, user) is True -def test_can_read_admin_sees_everything(): +def test_can_read_admin_cannot_read_private_of_others(): admin = _make_user(user_id="usr_admin", username="root", role="admin") session = _make_session(owner_user_id="usr_other", owner_username="bob") - assert SessionPolicy.can_read(session, admin) is True + assert SessionPolicy.can_read(session, admin) is False def test_can_read_private_hides_from_other_member(): @@ -47,21 +47,31 @@ def test_can_read_private_hides_from_other_member(): assert SessionPolicy.can_read(session, bob) is False -def test_can_delete_requires_admin_or_owner(): +def test_can_delete_requires_owner_only(): owner = _make_user() admin = _make_user(user_id="usr_admin", username="root", role="admin") stranger = _make_user(user_id="usr_x", username="x") session = _make_session() assert SessionPolicy.can_delete(session, owner) is True - assert SessionPolicy.can_delete(session, admin) is True + assert SessionPolicy.can_delete(session, admin) is False assert SessionPolicy.can_delete(session, stranger) is False -def test_can_read_requires_owner_or_admin(): +def test_can_read_requires_owner_for_private_session(): owner = _make_user() admin = _make_user(user_id="usr_admin", username="root", role="admin") stranger = _make_user(user_id="usr_x", username="x") session = _make_session() assert SessionPolicy.can_read(session, owner) is True - assert SessionPolicy.can_read(session, admin) is True + assert SessionPolicy.can_read(session, admin) is False assert SessionPolicy.can_read(session, stranger) is False + + +def test_can_read_local_shared_visible_to_all_local_users(): + owner = _make_user() + admin = _make_user(user_id="usr_admin", username="root", role="admin") + stranger = _make_user(user_id="usr_x", username="x") + session = _make_session(metadata={"shared_local": True}) + assert SessionPolicy.can_read(session, owner) is True + assert SessionPolicy.can_read(session, admin) is True + assert SessionPolicy.can_read(session, stranger) is True diff --git a/tests/session/test_session_policy_shared.py b/tests/session/test_session_policy_shared.py new file mode 100644 index 000000000..55ea44a32 --- /dev/null +++ b/tests/session/test_session_policy_shared.py @@ -0,0 +1,31 @@ +from flocks.auth.context import AuthUser +from flocks.session.policy import SessionPolicy +from flocks.session.session import SessionInfo, SessionTime + + +def _session_with_shared(shared_user_id: str) -> SessionInfo: + return SessionInfo( + id="s1", + slug="s1", + project_id="p1", + directory="/tmp", + title="t", + version="1.0.0", + time=SessionTime(created=1, updated=1), + owner_user_id="owner-1", + metadata={"shared_read_access_user_ids": [shared_user_id]}, + ) + + +def test_shared_user_can_read_but_cannot_write(): + shared_user = AuthUser( + id="member-1", + username="member", + role="member", + status="active", + must_reset_password=False, + ) + session = _session_with_shared(shared_user.id) + + assert SessionPolicy.can_read(session, shared_user) is True + assert SessionPolicy.can_write(session, shared_user) is False diff --git a/tests/test_extension_facades.py b/tests/test_extension_facades.py new file mode 100644 index 000000000..a35f6e9ae --- /dev/null +++ b/tests/test_extension_facades.py @@ -0,0 +1,49 @@ +import pytest + +from flocks.audit import NullAuditSink, emit_audit_event, get_sink, register_sink +from flocks.auth.service import AuthService, LocalAuthBackend +from flocks.license import ( + AlwaysOkLicenseChecker, + assert_license_active, + get_checker, + is_license_active, + license_status, + register_checker, +) + + +class IncompleteLicenseChecker: + @classmethod + async def is_active(cls, feature=None) -> bool: + return True + + +class IncompleteAuditSink: + pass + + +@pytest.mark.asyncio +async def test_oss_default_facades_remain_noop_and_local(): + AuthService.reset_backend() + register_checker(AlwaysOkLicenseChecker) + register_sink(NullAuditSink) + + assert AuthService.get_backend() is LocalAuthBackend + assert await is_license_active(feature="session_create") is True + assert (await license_status())["status"] == "oss" + await assert_license_active(feature="session_create") + await emit_audit_event("test_event", {"ok": True}) + + +def test_license_checker_contract_validation(): + register_checker(AlwaysOkLicenseChecker) + with pytest.raises(ValueError, match="接口不完整"): + register_checker(IncompleteLicenseChecker) + assert get_checker() is AlwaysOkLicenseChecker + + +def test_audit_sink_contract_validation(): + register_sink(NullAuditSink) + with pytest.raises(ValueError, match="接口不完整"): + register_sink(IncompleteAuditSink) + assert get_sink() is NullAuditSink diff --git a/tests/tool/test_write_tool.py b/tests/tool/test_write_tool.py index 5083eb72f..799869c69 100644 --- a/tests/tool/test_write_tool.py +++ b/tests/tool/test_write_tool.py @@ -12,8 +12,10 @@ import pytest from pathlib import Path from unittest.mock import patch +import datetime as dt from flocks.tool.registry import ToolRegistry, ToolContext +from flocks.workspace.manager import WorkspaceManager def _make_ctx(**extra_kwargs) -> ToolContext: @@ -129,6 +131,64 @@ async def test_write_expands_tilde_path(tmp_path, monkeypatch): assert target.read_text() == "home" +@pytest.mark.asyncio +async def test_filename_only_redirects_to_default_outputs(tmp_path, monkeypatch): + """Bare filename should go to workspace default outputs, not source dir.""" + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(tmp_path / "workspace")) + WorkspaceManager._instance = None + + project_dir = tmp_path / "project" + project_dir.mkdir(parents=True, exist_ok=True) + + ctx = _make_ctx() + with patch("flocks.tool.path_utils.Instance.get_directory", return_value=str(project_dir)): + result = await ToolRegistry.execute("write", ctx, filePath="hello.txt", content="hello") + + assert result.success, f"write failed: {result.error}" + expected = tmp_path / "workspace" / "outputs" / dt.date.today().isoformat() / "hello.txt" + assert expected.exists() + assert expected.read_text() == "hello" + assert not (project_dir / "hello.txt").exists() + + +@pytest.mark.asyncio +async def test_source_root_absolute_basename_redirects_to_default_outputs(tmp_path, monkeypatch): + """Absolute source-root basename should be treated as filename-only intent.""" + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(tmp_path / "workspace")) + WorkspaceManager._instance = None + + project_dir = tmp_path / "project" + project_dir.mkdir(parents=True, exist_ok=True) + source_root_target = project_dir / "report.txt" + + ctx = _make_ctx() + with patch("flocks.tool.path_utils.Instance.get_directory", return_value=str(project_dir)): + result = await ToolRegistry.execute( + "write", ctx, filePath=str(source_root_target), content="report" + ) + + assert result.success, f"write failed: {result.error}" + expected = tmp_path / "workspace" / "outputs" / dt.date.today().isoformat() / "report.txt" + assert expected.exists() + assert expected.read_text() == "report" + assert not source_root_target.exists() + + +@pytest.mark.asyncio +async def test_relative_with_subdir_keeps_project_path(tmp_path): + """Relative paths with explicit directory should keep existing semantics.""" + target = tmp_path / "project" / "notes" / "x.txt" + target.parent.mkdir(parents=True, exist_ok=True) + + ctx = _make_ctx() + with patch("flocks.tool.path_utils.Instance.get_directory", return_value=str(tmp_path / "project")): + result = await ToolRegistry.execute("write", ctx, filePath="notes/x.txt", content="x") + + assert result.success, f"write failed: {result.error}" + assert target.exists() + assert target.read_text() == "x" + + def test_filepath_parameter_references_env(): """filePath parameter description must contain directory routing rules.""" from flocks.tool.registry import ToolRegistry diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py new file mode 100644 index 000000000..901eb12e3 --- /dev/null +++ b/tests/updater/test_updater_console_manifest_bundle.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import zipfile + +import pytest + +from flocks.updater import updater + + +@pytest.mark.asyncio +async def test_fetch_console_manifest_release_uses_bundle_url(monkeypatch: pytest.MonkeyPatch) -> None: + from flocks.storage.storage import Storage + + await Storage.set("console:session", {"console_session_token": "cs_manifest"}, "json") + + class _Resp: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return { + "display_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", + "flockspro_component_version": "pro-v2026-5-10", + "release_notes": "bundle release", + } + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, headers=None, follow_redirects=True): + assert "channel=flockspro" in url + assert headers == {"Authorization": "Bearer cs_manifest"} + return _Resp() + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(updater.httpx, "AsyncClient", lambda timeout=15: _Client()) + result = await updater._fetch_console_manifest_release() + assert result == ( + "2026.5.10", + "bundle release", + "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", + None, + "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", + ) + info = await updater._fetch_console_manifest_release_info() + assert info.bundle_sha256 == "abc123" + assert info.bundle_format == "tar.gz" + + +@pytest.mark.asyncio +async def test_fetch_console_manifest_release_blocks_frozen_channel(monkeypatch: pytest.MonkeyPatch) -> None: + class _Resp: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return { + "display_version": "v2026.5.10", + "bundle_url": "https://cdn.example.com/flockspro-bundle-v2026.5.10.tar.gz", + "frozen": True, + } + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url, headers=None, follow_redirects=True): + return _Resp() + + monkeypatch.setenv("FLOCKS_CONSOLE_BASE_URL", "https://console.example.com") + monkeypatch.setattr(updater.httpx, "AsyncClient", lambda timeout=15: _Client()) + with pytest.raises(ValueError, match="frozen"): + await updater._fetch_console_manifest_release() + + +@pytest.mark.asyncio +async def test_perform_pro_bundle_install_only_installs_wheel( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + bundle_root = tmp_path / "bundle-root" + wheels = bundle_root / "wheels" + wheels.mkdir(parents=True) + wheel = wheels / "flockspro-0.1.0-py3-none-any.whl" + wheel.write_bytes(b"fake-wheel") + (bundle_root / "manifest.json").write_text( + """{ + "display_version": "v2026.5.10", + "oss_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" +}""", + encoding="utf-8", + ) + bundle = tmp_path / "flockspro-bundle.zip" + with zipfile.ZipFile(bundle, "w") as archive: + for path in bundle_root.rglob("*"): + if path.is_file(): + archive.write(path, path.relative_to(bundle_root).as_posix()) + + install_root = tmp_path / "install" + install_root.mkdir() + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / "flocks-root")) + monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) + monkeypatch.setattr(updater, "_fetch_console_manifest_release_info", lambda: _async_manifest_info(bundle)) + monkeypatch.setattr(updater, "_download_console_bundle", lambda *_args, **_kwargs: _async_path(bundle)) + monkeypatch.setattr(updater, "_verify_download_sha256", lambda *_args, **_kwargs: None) + monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr( + updater, + "_replace_install_dir", + lambda *_args, **_kwargs: pytest.fail("Pro upgrade must not replace OSS core"), + ) + + captured: dict[str, list[str]] = {} + + async def _fake_run_async(cmd, **_kwargs): + captured["cmd"] = cmd + return 0, "", "" + + monkeypatch.setattr(updater, "_run_async", _fake_run_async) + + progresses = [step async for step in updater.perform_pro_bundle_install(restart=False)] + assert progresses[-1].stage == "done" + assert captured["cmd"][:3] == ["/usr/bin/uv", "pip", "install"] + assert "--no-deps" in captured["cmd"] + assert str(wheel.name) in captured["cmd"][-1] + + +@pytest.mark.asyncio +async def test_perform_pro_bundle_install_schedules_restart_before_stream_can_close( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + bundle_root = tmp_path / "bundle-root" + wheels = bundle_root / "wheels" + wheels.mkdir(parents=True) + wheel = wheels / "flockspro-0.1.0-py3-none-any.whl" + wheel.write_bytes(b"fake-wheel") + (bundle_root / "manifest.json").write_text( + """{ + "display_version": "v2026.5.10", + "oss_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" +}""", + encoding="utf-8", + ) + bundle = tmp_path / "flockspro-bundle.zip" + with zipfile.ZipFile(bundle, "w") as archive: + for path in bundle_root.rglob("*"): + if path.is_file(): + archive.write(path, path.relative_to(bundle_root).as_posix()) + + install_root = tmp_path / "install" + venv_bin = install_root / ".venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("#!/usr/bin/env python\n", encoding="utf-8") + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / "flocks-root")) + monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) + monkeypatch.setattr(updater, "_fetch_console_manifest_release_info", lambda: _async_manifest_info(bundle)) + monkeypatch.setattr(updater, "_download_console_bundle", lambda *_args, **_kwargs: _async_path(bundle)) + monkeypatch.setattr(updater, "_verify_download_sha256", lambda *_args, **_kwargs: None) + monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) + + async def _fake_run_async(_cmd, **_kwargs): + return 0, "", "" + + monkeypatch.setattr(updater, "_run_async", _fake_run_async) + spawned: dict[str, object] = {} + + def _fake_spawn_detached_process(command, *, cwd, log_path): + spawned["command"] = command + spawned["cwd"] = cwd + spawned["log_path"] = log_path + + class _Process: + pid = 12345 + + return _Process() + + monkeypatch.setattr(updater, "_spawn_detached_process", _fake_spawn_detached_process) + scheduled: dict[str, object] = {} + + class _Loop: + def call_later(self, delay, callback): + scheduled["delay"] = delay + scheduled["callback"] = callback + return None + + monkeypatch.setattr(updater.asyncio, "get_running_loop", lambda: _Loop()) + + progresses = [] + async for step in updater.perform_pro_bundle_install(restart=True): + progresses.append(step) + if step.stage == "restarting": + break + + assert progresses[-1].stage == "restarting" + assert scheduled["delay"] == 0.8 + assert callable(scheduled["callback"]) + scheduled["callback"]() + assert spawned["command"][1:4] == ["-m", "flocks.cli.main", "restart"] + assert "--no-browser" in spawned["command"] + assert "--skip-webui-build" in spawned["command"] + + +async def _async_manifest_info(bundle): + return updater.ConsoleManifestRelease( + version="2026.5.10", + release_notes=None, + release_url=str(bundle), + bundle_url=str(bundle), + bundle_sha256=None, + bundle_format="zip", + manifest={ + "display_version": "v2026.5.10", + "oss_version": "v2026.5.10", + "flockspro_component_version": "pro-v2026-5-10", + "build_id": "job_test", + }, + ) + + +async def _async_path(path): + return path + diff --git a/tests/updater/test_updater_edition_sources.py b/tests/updater/test_updater_edition_sources.py new file mode 100644 index 000000000..f3c98e641 --- /dev/null +++ b/tests/updater/test_updater_edition_sources.py @@ -0,0 +1,48 @@ +import pytest + +from flocks.updater import updater +from flocks.updater.updater import _resolve_sources_for_edition + + +@pytest.mark.asyncio +async def test_flockspro_env_with_active_license_uses_console_manifest(monkeypatch): + monkeypatch.setenv("FLOCKS_EDITION", "flockspro") + monkeypatch.setattr("flocks.updater.updater._is_flockspro_license_active", lambda: True) + sources = await _resolve_sources_for_edition(["github", "gitee"]) + assert sources == ["console-manifest"] + + +@pytest.mark.asyncio +async def test_flockspro_env_without_active_license_keeps_oss_sources(monkeypatch): + monkeypatch.setenv("FLOCKS_EDITION", "flockspro") + monkeypatch.setattr("flocks.updater.updater._is_flockspro_license_active", lambda: False) + sources = await _resolve_sources_for_edition(["github", "gitee"]) + assert sources == ["github", "gitee"] + + +@pytest.mark.asyncio +async def test_console_session_does_not_change_oss_sources(monkeypatch): + from flocks.storage.storage import Storage + + monkeypatch.delenv("FLOCKS_EDITION", raising=False) + await Storage.set("console:session", {"console_session_token": "token_abc"}, "json") + + sources = await _resolve_sources_for_edition(["github", "gitee"]) + assert sources == ["github", "gitee"] + + +def test_flockspro_license_active_uses_runtime_capability(monkeypatch): + monkeypatch.setattr(updater.importlib.util, "find_spec", lambda name: object() if name == "flockspro" else None) + + import types + import sys + + runtime_module = types.ModuleType("flockspro.license.runtime") + runtime_module.is_pro_feature_enabled = lambda: True + license_module = types.ModuleType("flockspro.license") + flockspro_module = types.ModuleType("flockspro") + monkeypatch.setitem(sys.modules, "flockspro", flockspro_module) + monkeypatch.setitem(sys.modules, "flockspro.license", license_module) + monkeypatch.setitem(sys.modules, "flockspro.license.runtime", runtime_module) + + assert updater._is_flockspro_license_active() is True diff --git a/tests/workspace/test_workspace_manager.py b/tests/workspace/test_workspace_manager.py index c0bc9df96..cbcee1e2a 100644 --- a/tests/workspace/test_workspace_manager.py +++ b/tests/workspace/test_workspace_manager.py @@ -6,6 +6,7 @@ """ from pathlib import Path +import datetime as dt import pytest @@ -86,6 +87,19 @@ def test_ensure_dirs_idempotent(self, tmp_workspace: Path, manager): manager.ensure_dirs() manager.ensure_dirs() + def test_default_outputs_dir_uses_legacy_oss_layout(self, tmp_workspace: Path, manager): + outputs_dir = manager.get_default_outputs_dir(today=dt.date(2026, 5, 9)) + assert outputs_dir == tmp_workspace / "outputs" / "2026-05-09" + assert outputs_dir.is_dir() + + def test_default_outputs_dir_supports_username_layout(self, tmp_workspace: Path, manager): + outputs_dir = manager.get_default_outputs_dir( + username=" chen/jie ", + today=dt.date(2026, 5, 9), + ) + assert outputs_dir == tmp_workspace / "users" / "chen_jie" / "outputs" / "2026-05-09" + assert outputs_dir.is_dir() + # ─── Path resolution ────────────────────────────────────────────────────────── diff --git a/tests/workspace/test_workspace_manager_layout.py b/tests/workspace/test_workspace_manager_layout.py new file mode 100644 index 000000000..ef8c77ed3 --- /dev/null +++ b/tests/workspace/test_workspace_manager_layout.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from flocks.workspace.manager import WorkspaceManager + + +def test_workspace_migrate_single_user_layout(tmp_path: Path, monkeypatch): + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(tmp_path / "workspace")) + WorkspaceManager._instance = None + mgr = WorkspaceManager.get_instance() + + root = mgr.get_workspace_dir() + (root / "outputs").mkdir(parents=True, exist_ok=True) + (root / "knowledge").mkdir(parents=True, exist_ok=True) + (root / "outputs" / "a.txt").write_text("x", encoding="utf-8") + (root / "knowledge" / "k.md").write_text("k", encoding="utf-8") + + result = mgr.migrate_root_workspace_to_user("admin-1", dry_run=False) + assert result["moved_outputs"] is True + assert result["moved_knowledge"] is True + assert (root / "users" / "admin-1" / "outputs" / "a.txt").exists() + assert (root / "shared" / "knowledge" / "k.md").exists() diff --git a/webui/src/api/auth.ts b/webui/src/api/auth.ts index 17c37c170..936b3474f 100644 --- a/webui/src/api/auth.ts +++ b/webui/src/api/auth.ts @@ -21,6 +21,25 @@ export interface ResetPasswordResult { must_reset_password: boolean; } +export interface ConsoleLoginStartResult { + console_login_id: string; + passport_login_url: string; +} + +export interface ConsoleLoginFinishResult { + console_login_id: string; + logged_in: boolean; + account_name?: string | null; + updated_at?: string | null; +} + +export interface ConsoleLoginSessionStatus { + logged_in: boolean; + console_login_id?: string | null; + account_name?: string | null; + updated_at?: string | null; +} + export const authApi = { bootstrapStatus: async (): Promise => { const response = await client.get('/api/auth/bootstrap-status'); @@ -54,4 +73,33 @@ export const authApi = { const response = await client.post('/api/auth/reset-password'); return response.data; }, + + startConsoleLogin: async (returnTo: string): Promise => { + const response = await client.get('/api/auth/console-login/start', { + params: { return_to: returnTo }, + }); + return response.data; + }, + + finishConsoleLogin: async ( + consoleLoginId: string, + state?: string, + passportUid?: string, + ): Promise => { + const response = await client.post('/api/auth/console-login/finish', { + console_login_id: consoleLoginId, + ...(state ? { state } : {}), + ...(passportUid ? { passport_uid: passportUid } : {}), + }); + return response.data; + }, + + consoleLoginSession: async (): Promise => { + const response = await client.get('/api/auth/console-login/session'); + return response.data; + }, + + logoutConsoleLogin: async (): Promise => { + await client.post('/api/auth/console-login/logout'); + }, }; diff --git a/webui/src/api/consoleUpgrade.ts b/webui/src/api/consoleUpgrade.ts new file mode 100644 index 000000000..63c721e04 --- /dev/null +++ b/webui/src/api/consoleUpgrade.ts @@ -0,0 +1,163 @@ +import client from './client'; +import type { UpdateProgress } from './update'; + +export interface UpgradeRequestCreatePayload { + product: string; + license_type: 'trial_30d' | 'poc' | 'commercial'; + request_kind?: 'new' | 'trial_extension' | 'license_change'; + company: string; + applicant_name: string; + applicant_email?: string; + applicant_phone?: string; + notes?: string; +} + +export interface UpgradeRequestDetails { + product?: string; + license_type?: 'trial_30d' | 'poc' | 'commercial' | string; + license_status?: string | null; + expires_at?: number | string | null; + license_effective_expires_at?: number | string | null; + license_duration_days?: number | null; + license_id?: string | null; + max_admins?: number | null; + max_members?: number | null; + request_kind?: 'new' | 'trial_extension' | 'license_change' | string; + console_account_name?: string | null; + passport_uid?: string | null; + cloud_account?: string | null; + account?: string | null; + company?: string; + enterprise_name?: string; + applicant_name?: string; + applicant_email?: string | null; + applicant_phone?: string | null; + notes?: string | null; + auto_install_target?: string; + auto_install_version?: string; + auto_install_pro_version?: string; + flockspro_component_version?: string; + auto_install_result?: string; + auto_install_completed_at?: string; +} + +export interface UpgradeRequestStatus { + request_id: string; + status: string; + previous_request_id?: string | null; + reason?: string | null; + suggestion?: string | null; + activate_key?: string | null; + manifest_url?: string | null; + license_id?: string | null; + license_status?: string | null; + max_admins?: number | null; + max_members?: number | null; + expires_at?: number | string | null; + details?: UpgradeRequestDetails; + created_at: string; + updated_at: string; +} + +export interface ProPackageStatus { + installed: boolean; + installed_version?: string | null; + flockspro_component_version?: string | null; + build_id?: string | null; + installed_at?: string | null; + pro_enabled?: boolean | null; + license_status?: string | null; + inactive_reason?: string | null; +} + +export const consoleUpgradeApi = { + createRequest: async (payload: UpgradeRequestCreatePayload): Promise => { + const response = await client.post('/api/console/upgrade-requests', payload); + return response.data; + }, + + listRequests: async (): Promise => { + const response = await client.get('/api/console/upgrade-requests'); + return response.data; + }, + + getProPackageStatus: async (): Promise => { + const response = await client.get('/api/console/pro-package-status'); + return response.data; + }, + + syncRevocations: async (): Promise<{ + revoked_license_ids: string[]; + imported: boolean; + synced_license_ids?: string[]; + activated_license_id?: string | null; + refreshed_license_id?: string | null; + }> => { + const response = await client.post('/api/console/licenses/sync-revocations'); + return response.data; + }, + + getRequest: async (requestId: string): Promise => { + const response = await client.get(`/api/console/upgrade-requests/${requestId}`); + return response.data; + }, + + refreshRequest: async (requestId: string): Promise => { + const response = await client.post(`/api/console/upgrade-requests/${requestId}/refresh`); + return response.data; + }, + + cancelRequest: async (requestId: string): Promise => { + const response = await client.post(`/api/console/upgrade-requests/${requestId}/cancel`); + return response.data; + }, + + startRequest: (requestId: string, onProgress: (progress: UpdateProgress) => void): Promise => { + return new Promise((resolve, reject) => { + fetch(`/api/console/upgrade-requests/${encodeURIComponent(requestId)}/start`, { method: 'POST' }) + .then((res) => { + if (!res.ok || !res.body) { + reject(new Error(`HTTP ${res.status}`)); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const pump = (): Promise => + reader.read().then(({ done, value }) => { + if (done) { + resolve(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const progress: UpdateProgress = JSON.parse(line.slice(6)); + onProgress(progress); + if (progress.stage === 'error') { + reject(new Error(progress.message)); + return; + } + } catch { + // Ignore malformed SSE frames. + } + } + } + + return pump(); + }); + + pump().catch(reject); + }) + .catch(reject); + }); + }, +}; + diff --git a/webui/src/api/flocksproAudit.ts b/webui/src/api/flocksproAudit.ts new file mode 100644 index 000000000..1f865e496 --- /dev/null +++ b/webui/src/api/flocksproAudit.ts @@ -0,0 +1,60 @@ +import client from './client'; + +export interface AuditEventItem { + id: number; + event_type: string; + category: string; + action: string; + status: string; + result: string; + user_id?: string | null; + user_name?: string | null; + actor_id?: string | null; + actor_name?: string | null; + resource_id?: string | null; + resource_type?: string | null; + session_id?: string | null; + ip?: string | null; + trace_id?: string | null; + provider?: string | null; + model?: string | null; + tokens?: number | null; + estimated_cost?: number | null; + payload?: Record; + metadata?: Record; + created_at: string; +} + +export interface AuditEventListResponse { + items: AuditEventItem[]; + count: number; + total: number; + limit: number; + offset: number; +} + +export interface AuditEventQuery { + limit?: number; + offset?: number; + event_type?: string; + actor_id?: string; + username?: string; + user_id?: string; + resource_type?: string; + result?: string; + start_at?: string; + end_at?: string; + sort_by?: string; + order?: 'asc' | 'desc'; +} + +export const flocksproAuditApi = { + listEvents: async (query: AuditEventQuery = {}): Promise => { + const response = await client.get('/api/flockspro/audit/events', { params: query }); + return response.data; + }, + listEventTypes: async (): Promise => { + const response = await client.get('/api/flockspro/audit/event-types'); + return Array.isArray(response.data?.items) ? response.data.items : []; + }, +}; diff --git a/webui/src/api/flocksproUsers.ts b/webui/src/api/flocksproUsers.ts new file mode 100644 index 000000000..b3744891a --- /dev/null +++ b/webui/src/api/flocksproUsers.ts @@ -0,0 +1,86 @@ +import client from './client'; +import type { LocalUser } from './auth'; + +export interface FlocksproUserQuota { + max_admins: number; + max_members: number; + admin_count: number; + member_count: number; + pro_enabled?: boolean | null; + license_status?: string | null; +} + +export interface FlocksproCreateUserResult { + user: LocalUser; + temporary_password: string; + temporary_password_expires_at?: string | null; +} + +export interface FlocksproResetPasswordResult { + success: boolean; + temporary_password?: string | null; + must_reset_password: boolean; +} + +export interface FlocksproLicenseStatus { + activated?: boolean; + active?: boolean; + pro_enabled?: boolean; + license_id?: string | null; + status?: string | null; + license_status?: string | null; + inactive_reason?: string | null; + reapply_allowed?: boolean | null; +} + +export const flocksproUsersApi = { + hasCapability: async (): Promise => { + try { + const response = await client.get('/api/flockspro/license/status'); + return response.data?.pro_enabled === true; + } catch (err: any) { + const status = err?.response?.status; + if (status === 404 || status === 405 || status === 501) { + return false; + } + return false; + } + }, + + getLicenseStatus: async (): Promise => { + const response = await client.get('/api/flockspro/license/status'); + return response.data; + }, + + listUsers: async (): Promise => { + const response = await client.get('/api/flockspro/users'); + return response.data; + }, + + getQuota: async (): Promise => { + const response = await client.get('/api/flockspro/users/quota'); + return response.data; + }, + + createUser: async (payload: { username: string; role: 'admin' | 'member' }): Promise => { + const response = await client.post('/api/flockspro/users', payload); + return response.data; + }, + + updateUserRole: async (userId: string, role: 'admin' | 'member'): Promise => { + const response = await client.patch(`/api/flockspro/users/${userId}/role`, { role }); + return response.data; + }, + + deleteUser: async (userId: string): Promise => { + await client.delete(`/api/flockspro/users/${userId}`); + }, + + resetUserPassword: async ( + userId: string, + payload: { new_password?: string; force_reset?: boolean } = {}, + ): Promise => { + const response = await client.post(`/api/flockspro/users/${userId}/reset-password`, payload); + return response.data; + }, +}; diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index 4801c9aa7..c81748ed2 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -72,6 +72,22 @@ export const sessionApi = { return response.data; }, + /** + * 本地共享会话(所有本地账号可见,只读) + */ + shareLocal: async (sessionId: string) => { + const response = await client.post(`/api/session/${sessionId}/share-local`); + return response.data; + }, + + /** + * 取消本地共享会话 + */ + unshareLocal: async (sessionId: string) => { + const response = await client.post(`/api/session/${sessionId}/unshare-local`); + return response.data; + }, + /** * 清空会话消息 */ diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index f38c73983..eb64f4733 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -17,6 +17,8 @@ const { getActiveNotifications, ackNotification, getNotificationAckStatus, + flocksproUsersApi, + consoleUpgradeApi, useAuth, useStats, } = vi.hoisted(() => ({ @@ -43,6 +45,13 @@ const { getActiveNotifications: vi.fn(), ackNotification: vi.fn(), getNotificationAckStatus: vi.fn(), + flocksproUsersApi: { + hasCapability: vi.fn(), + getLicenseStatus: vi.fn(), + }, + consoleUpgradeApi: { + getProPackageStatus: vi.fn(), + }, useAuth: vi.fn(), useStats: vi.fn(), })); @@ -75,6 +84,14 @@ vi.mock('@/api/notifications', () => ({ getNotificationAckStatus, })); +vi.mock('@/api/flocksproUsers', () => ({ + flocksproUsersApi, +})); + +vi.mock('@/api/consoleUpgrade', () => ({ + consoleUpgradeApi, +})); + vi.mock('@/contexts/AuthContext', () => ({ useAuth, })); @@ -237,6 +254,13 @@ describe('Layout onboarding entry', () => { providerAPI.getServiceCredentials.mockResolvedValue({ data: { has_credential: false }, }); + flocksproUsersApi.hasCapability.mockResolvedValue(false); + flocksproUsersApi.getLicenseStatus.mockRejectedValue(new Error('Flocks Pro unavailable')); + consoleUpgradeApi.getProPackageStatus.mockResolvedValue({ + installed: false, + installed_version: null, + flockspro_component_version: null, + }); mcpAPI.getCredentials.mockResolvedValue({ data: { has_credential: false }, @@ -286,6 +310,31 @@ describe('Layout onboarding entry', () => { expect(checkUpdate).toHaveBeenCalledTimes(2); }); + it('skips GitHub-backed update checks when Flocks Pro is active', async () => { + vi.useFakeTimers(); + flocksproUsersApi.getLicenseStatus.mockResolvedValue({ + pro_enabled: true, + active: true, + status: 'active', + license_status: 'active', + }); + consoleUpgradeApi.getProPackageStatus.mockResolvedValue({ + installed: true, + installed_version: '2026.05.13-3', + flockspro_component_version: '2026.05.13-3', + }); + + renderHomeWithLayout(); + + await flushEffects(); + expect(checkUpdate).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_600_000); + }); + expect(checkUpdate).not.toHaveBeenCalled(); + }); + it('enforces a ten-minute minimum gap for focus-triggered update checks', async () => { vi.useFakeTimers(); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index dcbaba038..483e3dcf6 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -20,6 +20,7 @@ import { Archive, ServerCog, ScrollText, + ShieldCheck, } from 'lucide-react'; import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; import { useTranslation } from 'react-i18next'; @@ -40,18 +41,25 @@ const OnboardingModal = lazy(() => import('@/components/common/OnboardingModal') const UpdateModal = lazy(() => import('@/components/common/UpdateModal')); const NotificationModal = lazy(() => import('@/components/common/NotificationModal')); import { checkUpdate, type VersionInfo } from '@/api/update'; +import { consoleUpgradeApi } from '@/api/consoleUpgrade'; import { ackNotification, getActiveNotifications, getNotificationAckStatus, type UserNotification, } from '@/api/notifications'; +import { flocksproUsersApi } from '@/api/flocksproUsers'; import { useAuth } from '@/contexts/AuthContext'; import { getLocalizedReleaseNotes } from '@/utils/releaseNotes'; const UPDATE_CHECK_INTERVAL_MS = 3_600_000; const UPDATE_CHECK_MIN_GAP_MS = 600_000; +function formatProVersion(version?: string | null): string | null { + const normalized = (version || '').trim().replace(/^pro-v/i, '').replace(/^v/i, ''); + return normalized ? `pro-v${normalized}` : null; +} + function buildUpdateNotification(info: VersionInfo | null, language: string): UserNotification | null { const releaseNotes = getLocalizedReleaseNotes(info?.release_notes, language); if (!info || info.error || !releaseNotes) return null; @@ -95,6 +103,10 @@ export default function Layout() { const [updateNotificationReady, setUpdateNotificationReady] = useState(false); const [acknowledgingNotificationIds, setAcknowledgingNotificationIds] = useState([]); const lastNotificationFetchKeyRef = useRef(null); + const [hasFlocksproCapability, setHasFlocksproCapability] = useState(false); + const [isFlocksproActive, setIsFlocksproActive] = useState(false); + const [flocksproStatusReady, setFlocksproStatusReady] = useState(false); + const [flocksproVersion, setFlocksproVersion] = useState(null); // useLayoutEffect runs synchronously before paint, so there's no flash on initial load. // It also re-runs when the user navigates back to /, covering both cases in one place. useLayoutEffect(() => { @@ -111,6 +123,15 @@ export default function Layout() { }, [handleOpenOnboarding]); const refreshUpdateStatus = useCallback(async (force = false) => { + if (!flocksproStatusReady) return; + if (isFlocksproActive) { + setUpdateInfo(null); + setHasUpdate(false); + setLatestVersion(null); + setHasCompletedUpdateCheck(true); + return; + } + const now = Date.now(); if (checkingUpdateRef.current) return; if (!force && now - lastUpdateCheckAtRef.current < UPDATE_CHECK_MIN_GAP_MS) return; @@ -150,9 +171,11 @@ export default function Layout() { checkingUpdateRef.current = false; setHasCompletedUpdateCheck(true); } - }, [i18n.language]); + }, [flocksproStatusReady, i18n.language, isFlocksproActive]); useEffect(() => { + if (!flocksproStatusReady) return undefined; + refreshUpdateStatus(true); const intervalId = window.setInterval(() => { @@ -179,7 +202,83 @@ export default function Layout() { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleWindowFocus); }; - }, [refreshUpdateStatus]); + }, [flocksproStatusReady, refreshUpdateStatus]); + + useEffect(() => { + let cancelled = false; + if (!user?.id || user.role !== 'admin') { + setHasFlocksproCapability(false); + setFlocksproVersion(null); + return () => { + cancelled = true; + }; + } + const refreshCapability = () => { + void flocksproUsersApi.hasCapability() + .then((ok) => { + if (!cancelled) { + setHasFlocksproCapability(ok); + } + }) + .catch(() => { + if (!cancelled) { + setHasFlocksproCapability(false); + } + }); + }; + refreshCapability(); + window.addEventListener('flockspro-license-status-changed', refreshCapability); + return () => { + cancelled = true; + window.removeEventListener('flockspro-license-status-changed', refreshCapability); + }; + }, [user?.id, user?.role]); + + useEffect(() => { + let cancelled = false; + setFlocksproStatusReady(false); + if (!user?.id || user.role !== 'admin') { + setIsFlocksproActive(false); + setFlocksproVersion(null); + setFlocksproStatusReady(true); + return () => { + cancelled = true; + }; + } + const refreshFlocksproStatus = () => { + setFlocksproStatusReady(false); + void Promise.all([ + flocksproUsersApi.getLicenseStatus(), + consoleUpgradeApi.getProPackageStatus().catch(() => null), + ]) + .then(([licenseStatus, packageStatus]) => { + if (cancelled) return; + const active = licenseStatus.pro_enabled === true; + setIsFlocksproActive(active); + const version = active + ? formatProVersion(packageStatus?.flockspro_component_version || packageStatus?.installed_version) + : null; + setFlocksproVersion(version); + }) + .catch(() => { + if (!cancelled) { + setIsFlocksproActive(false); + setFlocksproVersion(null); + } + }) + .finally(() => { + if (!cancelled) { + setFlocksproStatusReady(true); + } + }); + }; + refreshFlocksproStatus(); + window.addEventListener('flockspro-license-status-changed', refreshFlocksproStatus); + return () => { + cancelled = true; + window.removeEventListener('flockspro-license-status-changed', refreshFlocksproStatus); + }; + }, [user?.id, user?.role]); useEffect(() => { if (!user?.id) { @@ -333,10 +432,16 @@ export default function Layout() { items: [ { name: t('accountManagement'), href: '/config', icon: UserCog }, { name: t('systemLog'), href: '/system-logs', icon: ScrollText }, + ...(hasFlocksproCapability && user?.role === 'admin' + ? [{ name: t('auditLogs'), href: '/audit-logs', icon: ShieldCheck }] + : []), + ...(user?.role === 'admin' + ? [{ name: t('flocksproUpgrade'), href: '/flockspro-upgrade', icon: ArrowUpCircle }] + : []), ], }, ], - [t], + [hasFlocksproCapability, t, user?.role], ); const isFullScreenPage = @@ -344,6 +449,15 @@ export default function Layout() { matchPath('/workflows/:id/edit', location.pathname) || matchPath('/workflows/:id', location.pathname) || matchPath('/sessions', location.pathname); + const productName = isFlocksproActive ? 'Flocks Pro' : 'Flocks'; + const displayVersion = isFlocksproActive + ? flocksproVersion || (currentVersion ? `v${currentVersion}` : null) + : currentVersion ? `v${currentVersion}` : null; + const currentVersionLabel = isFlocksproActive + ? t('currentProductVersionLabel', { version: displayVersion || productName }) + : currentVersion + ? t('currentVersionLabel', { version: currentVersion }) + : productName; return (
@@ -395,13 +509,13 @@ export default function Layout() { {collapsed ? (
) : ( <> - Flocks + {productName}
- {currentVersion - ? t('currentVersionLabel', { version: currentVersion }) - : 'Flocks'} + {currentVersionLabel}
AI Native SecOps Platform @@ -489,7 +601,7 @@ export default function Layout() { >
- Flocks {currentVersion ? `v${currentVersion}` : '...'} + {productName} {displayVersion || '...'}
AI Native SecOps Platform
diff --git a/webui/src/i18n.ts b/webui/src/i18n.ts index eefe8507f..e35511567 100644 --- a/webui/src/i18n.ts +++ b/webui/src/i18n.ts @@ -21,6 +21,7 @@ import enUpdate from './locales/en-US/update.json'; import enWorkspace from './locales/en-US/workspace.json'; import enAuth from './locales/en-US/auth.json'; import enNotification from './locales/en-US/notification.json'; +import enFlocksPro from './locales/en-US/flockspro.json'; import zhCommon from './locales/zh-CN/common.json'; import zhNav from './locales/zh-CN/nav.json'; @@ -41,6 +42,7 @@ import zhUpdate from './locales/zh-CN/update.json'; import zhWorkspace from './locales/zh-CN/workspace.json'; import zhAuth from './locales/zh-CN/auth.json'; import zhNotification from './locales/zh-CN/notification.json'; +import zhFlocksPro from './locales/zh-CN/flockspro.json'; i18n .use(LanguageDetector) @@ -67,6 +69,7 @@ i18n workspace: enWorkspace, auth: enAuth, notification: enNotification, + flockspro: enFlocksPro, }, 'zh-CN': { common: zhCommon, @@ -88,11 +91,12 @@ i18n workspace: zhWorkspace, auth: zhAuth, notification: zhNotification, + flockspro: zhFlocksPro, }, }, fallbackLng: 'en-US', defaultNS: 'common', - ns: ['common', 'nav', 'home', 'session', 'agent', 'task', 'workflow', 'tool', 'skill', 'model', 'mcp', 'config', 'channel', 'permission', 'monitoring', 'update', 'workspace', 'auth', 'notification'], + ns: ['common', 'nav', 'home', 'session', 'agent', 'task', 'workflow', 'tool', 'skill', 'model', 'mcp', 'config', 'channel', 'permission', 'monitoring', 'update', 'workspace', 'auth', 'notification', 'flockspro'], detection: { order: ['localStorage', 'navigator'], lookupLocalStorage: 'flocks-language', diff --git a/webui/src/locales/en-US/auth.json b/webui/src/locales/en-US/auth.json index a6fc57f59..dc86c5c4b 100644 --- a/webui/src/locales/en-US/auth.json +++ b/webui/src/locales/en-US/auth.json @@ -59,6 +59,7 @@ "columnActions": "Actions", "currentAccountTag": "Current session", "roleAdmin": "Administrator", + "roleMember": "Member", "resetAction": "Reset password", "resetConfirmTitle": "Reset password", "resetConfirmDescription": "Reset the password for the current account? The active session will be cleared and a one-time password will be generated for re-signing in.", @@ -71,7 +72,44 @@ "resetDialogPasswordLabel": "One-time password", "resetDialogWarning": "Copy and save the password now — it will not be shown again after this dialog is closed.", "resetDialogClose": "Close", - "resetDialogDone": "Copied, back to sign-in" + "resetDialogDone": "Copied, back to sign-in", + "pro": { + "loading": "Loading account management capability...", + "sectionDescription": "Manage local users, roles, and passwords for this node.", + "quotaHint": "Quota: admins {{adminCount}} / {{adminMax}}, members {{memberCount}} / {{memberMax}}", + "loadFailed": "Failed to load enterprise account management", + "createButton": "Create account", + "createDialogTitle": "Create account", + "createDialogFormDescription": "Enter a username and role. A one-time password will be generated automatically.", + "createUsernameRequired": "Please enter a username", + "cancelButton": "Cancel", + "confirmCreateButton": "Create", + "creatingButton": "Creating...", + "createFailed": "Failed to create account", + "createDialogDescription": "Account created. Copy the one-time password and share it with the user to sign in and change password immediately.", + "createDialogWarning": "This password is shown once only. Copy it now.", + "createDialogDone": "Copied", + "updateRoleConfirmTitle": "Update account role", + "updateRoleConfirmDescription": "Change {{username}} role to \"{{role}}\"?", + "updateRoleConfirmButton": "Confirm", + "updateRoleSuccess": "Role updated", + "updateRoleFailed": "Failed to update role", + "resetOtherAction": "Reset password", + "resetOtherConfirmTitle": "Reset account password", + "resetOtherConfirmDescription": "Reset password for {{username}}? A one-time password will be generated.", + "resetOtherConfirmButton": "Confirm reset", + "resetOtherSuccess": "Password reset", + "resetOtherFailed": "Failed to reset password", + "resetOtherDialogDescription": "Share this one-time password with the target user. They must change it after sign-in.", + "resetOtherDialogWarning": "This password is shown once only. Copy it now.", + "resetOtherDialogDone": "Done", + "deleteAction": "Delete", + "deleteConfirmTitle": "Delete account", + "deleteConfirmDescription": "Delete account {{username}}? This action cannot be undone.", + "deleteConfirmButton": "Delete", + "deleteSuccess": "Account deleted", + "deleteFailed": "Failed to delete account" + } }, "error": { "systemUnknownTitle": "Unable to determine system status", diff --git a/webui/src/locales/en-US/flockspro.json b/webui/src/locales/en-US/flockspro.json new file mode 100644 index 000000000..c8f17f7d6 --- /dev/null +++ b/webui/src/locales/en-US/flockspro.json @@ -0,0 +1,183 @@ +{ + "title": "Flocks Pro", + "description": "Complete console account login, upgrade application, and upgrade execution locally.", + "actions": { + "refresh": "Refresh", + "cancel": "Cancel", + "confirm": "OK", + "submit": "Submit Request", + "submitting": "Submitting..." + }, + "errors": { + "fetchConsoleLoginStatus": "Failed to load login status", + "startConsoleLogin": "Failed to start console account login", + "finishConsoleLogin": "Failed to complete console login callback", + "logoutConsoleLogin": "Failed to log out console account", + "fetchRequests": "Failed to load upgrade requests", + "createRequest": "Failed to create upgrade request", + "refreshRequest": "Failed to refresh request status", + "cancelRequest": "Failed to cancel request", + "startUpgrade": "Failed to start upgrade" + }, + "consoleLogin": { + "title": "Console Account Login", + "description": "Start login locally, sign in on console, and return to local confirmation.", + "accountLabel": "Console account: ", + "loading": "Loading...", + "bound": "Console account logged in", + "unbound": "Not logged in", + "unknownAccount": "Unknown account", + "missingAccountHint": "Current session does not include account name, please log out and log in again.", + "loginAction": "Console Login", + "logoutAction": "Log Out" + }, + "upgrade": { + "title": "Upgrade to Flocks Pro", + "description": "Review the current license information, submit applications, and track approval status.", + "installedTitle": "Flocks Pro {{version}}", + "installedDescription": "Flocks Pro is installed and the license is activated.", + "installedVersion": "Flocks Pro Version", + "licenseStatus": "License Status", + "licenseId": "License ID", + "expiresAt": "License Expires At", + "remainingDays": "License Remaining", + "remainingDaysValue": "{{count}} days", + "daysUnit": "days", + "quota": "Quota", + "adminQuotaValue": "{{count}} admin", + "memberQuotaValue": "{{count}} members", + "lastSyncedAt": "License Synced At", + "licenseDetails": "License Details", + "showLicenseDetails": "Show License Details", + "hideLicenseDetails": "Hide License Details", + "licenseFieldLabels": { + "license_id": "License ID", + "install_id": "Install ID", + "fingerprint": "Machine Fingerprint" + }, + "refreshing": "Refreshing...", + "syncLicenseAction": "Sync License Info", + "syncingLicense": "Syncing...", + "applyAction": "Apply for Upgrade", + "applyNewLicenseAction": "Apply for New License", + "revokedOrExpiredHint": "The current license is no longer valid. You can submit a new Flocks Pro application.", + "loginFirst": "Log in console account before applying for upgrade.", + "pendingRequestExists": "An upgrade request is already active. Refresh status or cancel it before creating a new one.", + "currentRequest": "Current Request", + "status": "Status", + "statusLabels": { + "pending": "Pending Review", + "reviewing": "In Review", + "approved": "Approved", + "activated": "Activated", + "rejected": "Rejected", + "cancelled": "Withdrawn", + "expired_unactivated": "Activation Expired" + }, + "licenseStatusLabels": { + "trial": "Trial", + "test": "Test", + "commercial": "Commercial", + "revoked": "Revoked", + "expired": "Expired", + "superseded": "Superseded", + "active": "Active" + }, + "licenseTypeLabels": { + "trial": "Trial", + "test": "Test", + "commercial": "Commercial" + }, + "updatedAt": "Approval Status Updated At", + "manualRefresh": "Refresh Approval Status", + "cancel": "Cancel Request", + "rejectedTitle": "Request rejected", + "dismissRejected": "Dismiss rejection feedback", + "startUpgrade": "Start Upgrade", + "inDevelopment": "Flocks Pro upgrade is under development", + "installingHint": "Downloading and installing the Flocks Pro component. OSS core will not be upgraded.", + "waitingRestart": "Upgrade is installed. Waiting for service restart...", + "restartTimeout": "Service restart timed out. Refresh later to confirm status.", + "afterUpgradeHint": "Starting upgrade will download the Pro bundle, install only the flockspro wheel, and restart automatically.", + "stageLabels": { + "fetching": "Download Pro bundle", + "backing_up": "Backup", + "applying": "Apply", + "syncing": "Install Pro wheel", + "building": "Build", + "restarting": "Restart service", + "done": "Done", + "error": "Failed" + }, + "noRequest": "No upgrade request yet.", + "history": "Request History", + "licenseHistory": "License History", + "historyColumns": { + "licenseId": "lic-id", + "licenseType": "License Type", + "status": "Status", + "appliedAt": "Applied At", + "expiresAt": "Expires At", + "durationDays": "Duration Days", + "account": "Cloud Account" + }, + "applyDialogTitle": "Flocks Pro Upgrade Application", + "productLabel": "Requested Version", + "licenseTypeLabel": "Requested License Type", + "licenseTypeTrial": "30-day Usage", + "licenseTypePoc": "POC License", + "licenseTypeCommercial": "Formal License", + "companyPlaceholderRequired": "Company (required)", + "applicantNamePlaceholderRequired": "Applicant Name (required)", + "applicantEmailPlaceholder": "Applicant Email (optional)", + "applicantPhonePlaceholder": "Applicant Phone (optional)", + "notesPlaceholder": "Notes (optional)", + "formRequiredError": "Company and applicant name are required" + }, + "callback": { + "processing": "Completing console login, please wait...", + "missingConsoleLoginId": "Missing console_login_id in callback URL.", + "exchangeFailed": "Failed to complete login exchange", + "failedTitle": "Login Failed" + }, + "audit": { + "title": "Audit Logs", + "description": "Review Pro audit events with filters for event type, actor, and time range.", + "loading": "Loading...", + "empty": "No audit events", + "total": "Total {{total}}", + "page": "Page {{page}} / {{pageCount}}", + "actions": { + "search": "Search", + "exportExcel": "Download Excel", + "exporting": "Downloading...", + "reset": "Reset", + "prev": "Prev", + "next": "Next" + }, + "filters": { + "eventType": "Event type (event_type)", + "allEventTypes": "All event types", + "actor": "Actor username (username)", + "allResults": "All results" + }, + "result": { + "success": "Success", + "failed": "Failed" + }, + "table": { + "time": "Time", + "eventType": "Event", + "actor": "Actor", + "resource": "Resource", + "result": "Result", + "payload": "Details" + }, + "errors": { + "fetch": "Failed to fetch audit logs", + "export": "Failed to export audit logs", + "forbidden": "Only admins can view audit logs", + "unavailable": "Audit logs are not available in this edition" + } + } +} diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index 65727c16b..7b54707aa 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -18,6 +18,8 @@ "systemCenter": "System Center", "accountManagement": "Account", "systemLog": "System Logs", + "flocksproUpgrade": "Flocks Pro", + "auditLogs": "Audit Logs", "expandNav": "Expand navigation", "collapseNav": "Collapse navigation", "switchLanguage": "Switch Language", @@ -26,5 +28,6 @@ "versionInfo": "Flocks version info", "updateAvailable": "Update available", "updateNow": "Upgrade now", - "currentVersionLabel": "Current v{{version}}" + "currentVersionLabel": "Current v{{version}}", + "currentProductVersionLabel": "Current {{version}}" } diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index e80dcbe14..2ee2f2efc 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -14,6 +14,12 @@ "renameEmpty": "Session name cannot be empty", "downloadJson": "Download JSON", "downloadFailed": "Download failed", + "sharedTag": "Shared", + "shareAction": "Share Locally", + "unshareAction": "Cancel Sharing", + "shareEnabled": "Session is shared to all local accounts as read-only", + "shareDisabled": "Session sharing canceled", + "shareUpdateFailed": "Failed to update sharing status", "moreActions": "More actions", "selectMode": "Batch select", "cancelSelect": "Cancel", diff --git a/webui/src/locales/zh-CN/auth.json b/webui/src/locales/zh-CN/auth.json index e6a81f2fc..405465706 100644 --- a/webui/src/locales/zh-CN/auth.json +++ b/webui/src/locales/zh-CN/auth.json @@ -59,6 +59,7 @@ "columnActions": "操作", "currentAccountTag": "当前登录账号", "roleAdmin": "管理员", + "roleMember": "普通成员", "resetAction": "重置密码", "resetConfirmTitle": "重置密码", "resetConfirmDescription": "确认重置当前账号密码吗?系统会清理当前登录态,并生成一次性密码供你重新登录。", @@ -71,7 +72,44 @@ "resetDialogPasswordLabel": "一次性密码", "resetDialogWarning": "请先复制保存,关闭弹窗后将无法再次直接看到这串密码。", "resetDialogClose": "关闭", - "resetDialogDone": "已复制,返回登录" + "resetDialogDone": "已复制,返回登录", + "pro": { + "loading": "正在加载账号管理能力...", + "sectionDescription": "管理本节点所有本地账号、角色与密码。", + "quotaHint": "配额:管理员 {{adminCount}} / {{adminMax}},成员 {{memberCount}} / {{memberMax}}", + "loadFailed": "加载企业账号管理失败", + "createButton": "创建账号", + "createDialogTitle": "创建账号", + "createDialogFormDescription": "输入账号名并选择角色,系统会自动生成一次性密码。", + "createUsernameRequired": "请输入账号名", + "cancelButton": "取消", + "confirmCreateButton": "创建", + "creatingButton": "创建中...", + "createFailed": "创建账号失败", + "createDialogDescription": "账号创建成功,请复制一次性密码并交给用户登录后立即修改。", + "createDialogWarning": "该密码仅展示一次,请立即复制保存。", + "createDialogDone": "已复制", + "updateRoleConfirmTitle": "修改账号角色", + "updateRoleConfirmDescription": "确认将 {{username}} 的角色修改为“{{role}}”?", + "updateRoleConfirmButton": "确认修改", + "updateRoleSuccess": "角色已更新", + "updateRoleFailed": "修改角色失败", + "resetOtherAction": "重置密码", + "resetOtherConfirmTitle": "重置账号密码", + "resetOtherConfirmDescription": "确认重置账号 {{username}} 的密码吗?系统将生成一次性密码。", + "resetOtherConfirmButton": "确认重置", + "resetOtherSuccess": "密码已重置", + "resetOtherFailed": "重置密码失败", + "resetOtherDialogDescription": "请将一次性密码交给目标用户,用户登录后需立即修改。", + "resetOtherDialogWarning": "该密码仅展示一次,请立即复制保存。", + "resetOtherDialogDone": "完成", + "deleteAction": "删除账号", + "deleteConfirmTitle": "删除账号", + "deleteConfirmDescription": "确认删除账号 {{username}} 吗?该操作不可撤销。", + "deleteConfirmButton": "确认删除", + "deleteSuccess": "账号已删除", + "deleteFailed": "删除账号失败" + } }, "error": { "systemUnknownTitle": "无法确认当前系统状态", diff --git a/webui/src/locales/zh-CN/flockspro.json b/webui/src/locales/zh-CN/flockspro.json new file mode 100644 index 000000000..e07e245e5 --- /dev/null +++ b/webui/src/locales/zh-CN/flockspro.json @@ -0,0 +1,183 @@ +{ + "title": "Flocks Pro", + "description": "在本地完成云账号登录、升级申请与升级执行。", + "actions": { + "refresh": "刷新", + "cancel": "取消", + "confirm": "确定", + "submit": "提交申请", + "submitting": "提交中..." + }, + "errors": { + "fetchConsoleLoginStatus": "获取登录状态失败", + "startConsoleLogin": "发起云账号登录失败", + "finishConsoleLogin": "云登录回调处理失败", + "logoutConsoleLogin": "退出登录失败", + "fetchRequests": "获取升级申请失败", + "createRequest": "创建升级申请失败", + "refreshRequest": "刷新申请状态失败", + "cancelRequest": "撤回申请失败", + "startUpgrade": "开始升级失败" + }, + "consoleLogin": { + "title": "云账号登录", + "description": "在本地发起登录,跳转云端登录,完成后回跳本地确认。", + "accountLabel": "云账户:", + "loading": "加载中...", + "bound": "已登录云账号", + "unbound": "未登录", + "unknownAccount": "未知账号", + "missingAccountHint": "当前会话未携带云账号名,请退出登录后重新登录。", + "loginAction": "云账号登录", + "logoutAction": "退出登录" + }, + "upgrade": { + "title": "升级为Flocks Pro", + "description": "查看当前授权信息、提交申请并跟踪审批进度。", + "installedTitle": "Flocks Pro {{version}}", + "installedDescription": "Flocks Pro 已安装并完成授权激活。", + "installedVersion": "Flocks Pro 版本", + "licenseStatus": "License 状态", + "licenseId": "License ID", + "expiresAt": "License 到期时间", + "remainingDays": "License 剩余时间", + "remainingDaysValue": "{{count}} 天", + "daysUnit": "天", + "quota": "授权额度", + "adminQuotaValue": "{{count}} 管理员", + "memberQuotaValue": "{{count}} 成员", + "lastSyncedAt": "授权信息同步时间", + "licenseDetails": "License 详情", + "showLicenseDetails": "查看 License 详情", + "hideLicenseDetails": "收起 License 详情", + "licenseFieldLabels": { + "license_id": "License ID", + "install_id": "安装 ID", + "fingerprint": "机器指纹" + }, + "refreshing": "刷新中...", + "syncLicenseAction": "同步授权信息", + "syncingLicense": "同步中...", + "applyAction": "申请升级", + "applyNewLicenseAction": "申请新授权", + "revokedOrExpiredHint": "当前 License 已失效,可重新提交 Flocks Pro 申请。", + "loginFirst": "请先完成云账号登录后再申请升级。", + "pendingRequestExists": "当前已有升级申请在处理中,请刷新状态或撤回后再发起新申请。", + "currentRequest": "当前申请", + "status": "状态", + "statusLabels": { + "pending": "待审批", + "reviewing": "审批中", + "approved": "已通过", + "activated": "已激活", + "rejected": "已拒绝", + "cancelled": "已撤回", + "expired_unactivated": "已超时" + }, + "licenseStatusLabels": { + "trial": "试用", + "test": "测试授权", + "commercial": "商业授权", + "revoked": "已吊销", + "expired": "已到期", + "superseded": "已替换", + "active": "有效" + }, + "licenseTypeLabels": { + "trial": "试用", + "test": "测试授权", + "commercial": "商业授权" + }, + "updatedAt": "审批状态更新时间", + "manualRefresh": "刷新审批状态", + "cancel": "撤回申请", + "rejectedTitle": "申请已被拒绝", + "dismissRejected": "关闭拒绝反馈", + "startUpgrade": "开始升级", + "inDevelopment": "Flocks Pro升级正在开发中", + "installingHint": "正在下载并安装 Flocks Pro 组件,期间不会升级 OSS core。", + "waitingRestart": "升级已安装,正在等待服务重启完成...", + "restartTimeout": "服务重启超时,请稍后手动刷新页面确认状态。", + "afterUpgradeHint": "开始升级后将下载 Pro bundle,仅安装 flockspro wheel,并在完成后自动重启。", + "stageLabels": { + "fetching": "下载 Pro bundle", + "backing_up": "备份", + "applying": "应用", + "syncing": "安装 Pro wheel", + "building": "构建", + "restarting": "重启服务", + "done": "完成", + "error": "失败" + }, + "noRequest": "暂无升级申请记录。", + "history": "申请历史", + "licenseHistory": "历史 License", + "historyColumns": { + "licenseId": "lic-id", + "licenseType": "授权类型", + "status": "状态", + "appliedAt": "申请时间", + "expiresAt": "到期时间", + "durationDays": "有效期天数", + "account": "云账号" + }, + "applyDialogTitle": "Flocks Pro 升级申请", + "productLabel": "申请的版本", + "licenseTypeLabel": "申请的授权类型", + "licenseTypeTrial": "30天使用", + "licenseTypePoc": "POC授权", + "licenseTypeCommercial": "正式授权", + "companyPlaceholderRequired": "公司(必填)", + "applicantNamePlaceholderRequired": "申请人姓名(必填)", + "applicantEmailPlaceholder": "申请人邮箱(选填)", + "applicantPhonePlaceholder": "申请人电话(选填)", + "notesPlaceholder": "申请说明(选填)", + "formRequiredError": "请填写公司和申请人姓名" + }, + "callback": { + "processing": "正在完成云账号登录,请稍候...", + "missingConsoleLoginId": "缺少 console_login_id,无法完成登录。", + "exchangeFailed": "登录回调处理失败", + "failedTitle": "登录失败" + }, + "audit": { + "title": "审计日志", + "description": "查看 Pro 审计事件,支持按事件类型、用户与时间范围筛选。", + "loading": "加载中...", + "empty": "暂无审计记录", + "total": "共 {{total}} 条", + "page": "第 {{page}} / {{pageCount}} 页", + "actions": { + "search": "查询", + "exportExcel": "下载 Excel", + "exporting": "下载中...", + "reset": "重置", + "prev": "上一页", + "next": "下一页" + }, + "filters": { + "eventType": "事件类型(event_type)", + "allEventTypes": "全部事件类型", + "actor": "操作者用户名(username)", + "allResults": "全部结果" + }, + "result": { + "success": "成功", + "failed": "失败" + }, + "table": { + "time": "时间", + "eventType": "事件", + "actor": "操作者", + "resource": "资源", + "result": "结果", + "payload": "详情" + }, + "errors": { + "fetch": "获取审计日志失败", + "export": "导出审计日志失败", + "forbidden": "仅管理员可查看审计日志", + "unavailable": "当前版本不支持审计日志" + } + } +} diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index f82f2605c..da698432b 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -18,6 +18,8 @@ "systemCenter": "系统中心", "accountManagement": "账号管理", "systemLog": "系统日志", + "flocksproUpgrade": "Flocks Pro", + "auditLogs": "审计日志", "expandNav": "展开导航", "collapseNav": "收起导航", "switchLanguage": "切换语言", @@ -26,5 +28,6 @@ "versionInfo": "Flocks 版本信息", "updateAvailable": "发现新版本", "updateNow": "立即升级", - "currentVersionLabel": "当前 v{{version}}" + "currentVersionLabel": "当前 v{{version}}", + "currentProductVersionLabel": "当前 {{version}}" } diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 1716c7585..60aeefb6a 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -14,6 +14,12 @@ "renameEmpty": "会话名称不能为空", "downloadJson": "下载 JSON", "downloadFailed": "下载失败", + "sharedTag": "已共享", + "shareAction": "本地共享", + "unshareAction": "取消共享", + "shareEnabled": "会话已共享给本地账号(只读)", + "shareDisabled": "已取消会话共享", + "shareUpdateFailed": "更新共享状态失败", "moreActions": "更多操作", "selectMode": "批量选择", "cancelSelect": "取消", diff --git a/webui/src/pages/AdminUsers/index.tsx b/webui/src/pages/AdminUsers/index.tsx index f5f5205fd..d6cfb94ed 100644 --- a/webui/src/pages/AdminUsers/index.tsx +++ b/webui/src/pages/AdminUsers/index.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { authApi } from '@/api/auth'; +import { authApi, type LocalUser } from '@/api/auth'; +import { flocksproUsersApi, type FlocksproUserQuota } from '@/api/flocksproUsers'; import CopyButton from '@/components/common/CopyButton'; import { useAuth } from '@/contexts/AuthContext'; import { useToast } from '@/components/common/Toast'; @@ -13,21 +14,89 @@ function formatDateTime(value: string | null | undefined, locale: string) { return date.toLocaleString(locale, { hour12: false }); } +function roleLabel(role: string, t: (key: string) => string) { + return role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember'); +} + export default function AdminUsersPage() { const { t, i18n } = useTranslation('auth'); const { user, logout } = useAuth(); const toast = useToast(); const confirm = useConfirm(); - const [resetCredential, setResetCredential] = useState<{ + const [loading, setLoading] = useState(true); + const [isProEnabled, setIsProEnabled] = useState(false); + const [users, setUsers] = useState([]); + const [quota, setQuota] = useState(null); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [createForm, setCreateForm] = useState<{ username: string; role: 'admin' | 'member' }>({ + username: '', + role: 'member', + }); + const [creating, setCreating] = useState(false); + const [credentialModal, setCredentialModal] = useState<{ username: string; password: string; + description: string; + doneText: string; + warning: string; + logoutAfterClose: boolean; } | null>(null); - const closeResetCredentialModal = () => { - setResetCredential(null); - void logout(); + const showProAdminView = isProEnabled && user?.role === 'admin'; + const sortedUsers = useMemo( + () => [...users].sort((a, b) => a.username.localeCompare(b.username)), + [users], + ); + + const closeCredentialModal = () => { + const shouldLogout = credentialModal?.logoutAfterClose; + setCredentialModal(null); + if (shouldLogout) { + void logout(); + } }; + const loadProUsers = async () => { + const [userList, quotaSnapshot] = await Promise.all([ + flocksproUsersApi.listUsers(), + flocksproUsersApi.getQuota(), + ]); + setUsers(userList); + setQuota(quotaSnapshot); + }; + + useEffect(() => { + let mounted = true; + const loadMode = async () => { + if (!user) { + if (mounted) setLoading(false); + return; + } + setLoading(true); + const enabled = await flocksproUsersApi.hasCapability(); + if (!mounted) return; + setIsProEnabled(enabled); + if (!enabled || user.role !== 'admin') { + setLoading(false); + return; + } + try { + await loadProUsers(); + } catch (err: any) { + toast.error( + t('admin.pro.loadFailed'), + err?.response?.data?.detail || err?.message || t('admin.pro.loadFailed'), + ); + } finally { + if (mounted) setLoading(false); + } + }; + void loadMode(); + return () => { + mounted = false; + }; + }, [user, toast, t]); + const resetOwnPassword = async () => { const confirmed = await confirm({ title: t('admin.resetConfirmTitle'), @@ -39,9 +108,13 @@ export default function AdminUsersPage() { try { const result = await authApi.resetPassword(); if (result.temporary_password && user) { - setResetCredential({ + setCredentialModal({ username: user.username, password: result.temporary_password, + description: t('admin.resetDialogDescription'), + doneText: t('admin.resetDialogDone'), + warning: t('admin.resetDialogWarning'), + logoutAfterClose: true, }); } else { toast.success(t('admin.resetSuccessToast')); @@ -55,11 +128,154 @@ export default function AdminUsersPage() { } }; + const createUser = async () => { + const username = createForm.username.trim(); + if (!username) { + toast.error(t('admin.pro.createUsernameRequired')); + return; + } + setCreating(true); + try { + const result = await flocksproUsersApi.createUser({ + username, + role: createForm.role, + }); + await loadProUsers(); + setCreateModalOpen(false); + setCreateForm({ username: '', role: 'member' }); + setCredentialModal({ + username: result.user.username, + password: result.temporary_password, + description: t('admin.pro.createDialogDescription'), + doneText: t('admin.pro.createDialogDone'), + warning: t('admin.pro.createDialogWarning'), + logoutAfterClose: false, + }); + } catch (err: any) { + toast.error( + t('admin.pro.createFailed'), + err?.response?.data?.detail || err?.message || t('admin.pro.createFailed'), + ); + } finally { + setCreating(false); + } + }; + + const updateUserRole = async ( + targetUserId: string, + username: string, + role: 'admin' | 'member', + prevRole: 'admin' | 'member', + ) => { + if (role === prevRole) return; + const confirmed = await confirm({ + title: t('admin.pro.updateRoleConfirmTitle'), + description: t('admin.pro.updateRoleConfirmDescription', { + username, + role: roleLabel(role, t), + }), + confirmText: t('admin.pro.updateRoleConfirmButton'), + }); + if (!confirmed) return; + try { + await flocksproUsersApi.updateUserRole(targetUserId, role); + await loadProUsers(); + toast.success(t('admin.pro.updateRoleSuccess')); + } catch (err: any) { + toast.error( + t('admin.pro.updateRoleFailed'), + err?.response?.data?.detail || err?.message || t('admin.pro.updateRoleFailed'), + ); + await loadProUsers(); + } + }; + + const resetOtherUserPassword = async (targetUserId: string, username: string) => { + const confirmed = await confirm({ + title: t('admin.pro.resetOtherConfirmTitle'), + description: t('admin.pro.resetOtherConfirmDescription', { username }), + confirmText: t('admin.pro.resetOtherConfirmButton'), + variant: 'warning', + }); + if (!confirmed) return; + try { + const result = await flocksproUsersApi.resetUserPassword(targetUserId, { force_reset: true }); + if (result.temporary_password) { + setCredentialModal({ + username, + password: result.temporary_password, + description: t('admin.pro.resetOtherDialogDescription'), + doneText: t('admin.pro.resetOtherDialogDone'), + warning: t('admin.pro.resetOtherDialogWarning'), + logoutAfterClose: false, + }); + } else { + toast.success(t('admin.pro.resetOtherSuccess')); + } + } catch (err: any) { + toast.error( + t('admin.pro.resetOtherFailed'), + err?.response?.data?.detail || err?.message || t('admin.pro.resetOtherFailed'), + ); + } + }; + + const deleteUser = async (targetUserId: string, username: string) => { + const confirmed = await confirm({ + title: t('admin.pro.deleteConfirmTitle'), + description: t('admin.pro.deleteConfirmDescription', { username }), + confirmText: t('admin.pro.deleteConfirmButton'), + variant: 'danger', + }); + if (!confirmed) return; + try { + await flocksproUsersApi.deleteUser(targetUserId); + await loadProUsers(); + toast.success(t('admin.pro.deleteSuccess')); + } catch (err: any) { + toast.error( + t('admin.pro.deleteFailed'), + err?.response?.data?.detail || err?.message || t('admin.pro.deleteFailed'), + ); + } + }; + + if (loading) { + return ( +
+ {t('admin.pro.loading')} +
+ ); + } + + const sectionDescription = showProAdminView ? t('admin.pro.sectionDescription') : t('admin.sectionDescription'); + return (
-
-

{t('admin.sectionTitle')}

-

{t('admin.sectionDescription')}

+
+
+

{t('admin.sectionTitle')}

+

{sectionDescription}

+ {showProAdminView && quota && ( +

+ {t('admin.pro.quotaHint', { + adminCount: quota.admin_count, + adminMax: quota.max_admins, + memberCount: quota.member_count, + memberMax: quota.max_members, + })} +

+ )} +
+ {showProAdminView && ( + + )}
@@ -73,13 +289,79 @@ export default function AdminUsersPage() { - {user && ( + {showProAdminView ? sortedUsers.map((item) => { + const isCurrent = user?.id === item.id; + return ( + + +
{item.username}
+ {isCurrent && ( +
{t('admin.currentAccountTag')}
+ )} + + + {isCurrent ? ( + {roleLabel(item.role, t)} + ) : ( + + )} + + + {formatDateTime(item.last_login_at, i18n.language)} + + +
+ {isCurrent ? ( + + ) : ( + <> + + + + )} +
+ + + ); + }) : user && (
{user.username}
{t('admin.currentAccountTag')}
- {t('admin.roleAdmin')} + {roleLabel(user.role, t)} {formatDateTime(user.last_login_at, i18n.language)} @@ -98,19 +380,76 @@ export default function AdminUsersPage() {
- {resetCredential && ( + {createModalOpen && ( + <> +
setCreateModalOpen(false)} /> +
+
+

{t('admin.pro.createDialogTitle')}

+

{t('admin.pro.createDialogFormDescription')}

+
+
+ + setCreateForm((prev) => ({ ...prev, username: event.target.value }))} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" + placeholder={t('fields.usernamePlaceholder')} + /> +
+
+ + +
+
+
+ + +
+
+
+ + )} + + {credentialModal && ( <> -
+

{t('admin.resetDialogTitle')}

-

{t('admin.resetDialogDescription')}

+

{credentialModal.description}

diff --git a/webui/src/pages/AuditLogs/index.tsx b/webui/src/pages/AuditLogs/index.tsx new file mode 100644 index 000000000..74e0f16b7 --- /dev/null +++ b/webui/src/pages/AuditLogs/index.tsx @@ -0,0 +1,570 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Download, Loader2, ShieldCheck } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import PageHeader from '@/components/common/PageHeader'; +import { useAuth } from '@/contexts/AuthContext'; +import { flocksproAuditApi, type AuditEventItem } from '@/api/flocksproAudit'; +import { flocksproUsersApi } from '@/api/flocksproUsers'; + +const PAGE_SIZE = 20; +const EXPORT_PAGE_SIZE = 500; + +interface AuditFilters { + eventType: string; + actorId: string; + result: string; + startAt: string; + endAt: string; +} + +const EMPTY_FILTERS: AuditFilters = { + eventType: '', + actorId: '', + result: '', + startAt: '', + endAt: '', +}; + +function toLocalTimestampOrEmpty(value: string): string | undefined { + if (!value) return undefined; + return value.length === 16 ? `${value}:00` : value; +} + +function formatLocalTime(value: string): string { + if (!value) return '-'; + const normalized = value.includes('T') ? value : value.replace(' ', 'T'); + const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized); + const parsed = new Date(hasTimezone ? normalized : `${normalized}Z`); + if (Number.isNaN(parsed.getTime())) return value; + const year = parsed.getFullYear(); + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + const hour = String(parsed.getHours()).padStart(2, '0'); + const minute = String(parsed.getMinutes()).padStart(2, '0'); + const second = String(parsed.getSeconds()).padStart(2, '0'); + return `${year}/${month}/${day} ${hour}:${minute}:${second}`; +} + +function payloadPreview(item: AuditEventItem): string { + const data = item.payload ?? item.metadata ?? {}; + const serialized = JSON.stringify(data, null, 2); + if (!serialized || serialized === '{}') return '-'; + return serialized.length > 260 ? `${serialized.slice(0, 257)}...` : serialized; +} + +function payloadFullText(item: AuditEventItem): string { + const data = item.payload ?? item.metadata ?? {}; + const serialized = JSON.stringify(data, null, 2); + return !serialized || serialized === '{}' ? '-' : serialized; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function exportFilename(): string { + const stamp = new Date() + .toLocaleString('sv-SE', { hour12: false }) + .replace(/[^\d]/g, '') + .slice(0, 14); + return `audit_logs_${stamp}.xls`; +} + +function parseAuditTime(value: string): number { + if (!value) return 0; + const valueTrimmed = value.trim(); + const mmdd = valueTrimmed.match( + /^(\d{2})\/(\d{2})\/(\d{4})(?:,\s*|\s+)(\d{2}):(\d{2}):(\d{2})$/, + ); + if (mmdd) { + const [, month, day, year, hour, minute, second] = mmdd; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ).getTime(); + } + const ymd = valueTrimmed.match( + /^(\d{4})\/(\d{2})\/(\d{2})(?:,\s*|\s+)(\d{2}):(\d{2}):(\d{2})$/, + ); + if (ymd) { + const [, year, month, day, hour, minute, second] = ymd; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ).getTime(); + } + const normalized = valueTrimmed.includes('T') ? valueTrimmed : valueTrimmed.replace(' ', 'T'); + const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized); + const parsed = new Date(hasTimezone ? normalized : `${normalized}Z`); + return Number.isNaN(parsed.getTime()) ? 0 : parsed.getTime(); +} + +function sortByCreatedAtDesc(items: AuditEventItem[]): AuditEventItem[] { + return [...items].sort((a, b) => { + const delta = parseAuditTime(b.created_at) - parseAuditTime(a.created_at); + if (delta !== 0) return delta; + return (b.id ?? 0) - (a.id ?? 0); + }); +} + +function stringFromPayload(item: AuditEventItem, keys: string[]): string | undefined { + const data = item.payload ?? item.metadata ?? {}; + for (const key of keys) { + const value = data[key]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return undefined; +} + +function actorLabel(item: AuditEventItem): string { + return ( + item.user_name + || item.actor_name + || stringFromPayload(item, ['user_name', 'username', 'actor_name', 'actor_id']) + || item.user_id + || item.actor_id + || '-' + ); +} + +function buildAuditQuery(filters: AuditFilters) { + return { + event_type: filters.eventType || undefined, + username: filters.actorId || undefined, + result: filters.result || undefined, + start_at: toLocalTimestampOrEmpty(filters.startAt), + end_at: toLocalTimestampOrEmpty(filters.endAt), + sort_by: 'created_at', + order: 'desc' as const, + }; +} + +export default function AuditLogsPage() { + const { t } = useTranslation('flockspro'); + const { user } = useAuth(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [eventType, setEventType] = useState(''); + const [actorId, setActorId] = useState(''); + const [result, setResult] = useState(''); + const [startAt, setStartAt] = useState(''); + const [endAt, setEndAt] = useState(''); + const [expandedRowId, setExpandedRowId] = useState(null); + const [exporting, setExporting] = useState(false); + const [eventTypeOptions, setEventTypeOptions] = useState([]); + const [checkingCapability, setCheckingCapability] = useState(true); + const [hasFlocksproCapability, setHasFlocksproCapability] = useState(false); + + const page = useMemo(() => Math.floor(offset / PAGE_SIZE) + 1, [offset]); + const pageCount = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]); + const currentFilters: AuditFilters = { + eventType, + actorId, + result, + startAt, + endAt, + }; + const load = async (nextOffset: number, filters: AuditFilters = currentFilters) => { + setLoading(true); + setError(null); + try { + const query = buildAuditQuery(filters); + const response = await flocksproAuditApi.listEvents({ + ...query, + limit: PAGE_SIZE, + offset: nextOffset, + }); + setItems(sortByCreatedAtDesc(response.items)); + setTotal(response.total ?? response.count ?? response.items.length); + setOffset(nextOffset); + } catch (err: any) { + const code = err?.response?.status; + if (code === 403) { + setError(t('audit.errors.forbidden')); + } else { + setError(err?.response?.data?.message || err?.message || t('audit.errors.fetch')); + } + } finally { + setLoading(false); + } + }; + + const exportToExcel = async () => { + setExporting(true); + setError(null); + try { + const query = buildAuditQuery(currentFilters); + const allItems: AuditEventItem[] = []; + let nextOffset = 0; + let expectedTotal: number | null = null; + while (expectedTotal === null || allItems.length < expectedTotal) { + const response = await flocksproAuditApi.listEvents({ + ...query, + limit: EXPORT_PAGE_SIZE, + offset: nextOffset, + }); + allItems.push(...response.items); + expectedTotal = response.total ?? response.count ?? allItems.length; + if (response.items.length === 0 || response.items.length < EXPORT_PAGE_SIZE) break; + nextOffset += response.items.length; + } + const sortedItems = sortByCreatedAtDesc(allItems); + + const headers = [ + t('audit.table.time'), + t('audit.table.eventType'), + t('audit.table.actor'), + t('audit.table.resource'), + t('audit.table.result'), + t('audit.table.payload'), + ]; + const rows = sortedItems.map((item) => { + const resource = item.resource_type ? `${item.resource_type}:${item.resource_id || '-'}` : '-'; + return [ + formatLocalTime(item.created_at), + item.event_type, + actorLabel(item), + resource, + item.result || item.status || '-', + payloadFullText(item), + ]; + }); + const html = ` + + + + + + + + ${headers.map((header) => ``).join('')} + + ${rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')} + +
${escapeHtml(header)}
${escapeHtml(String(cell)).replace(/\n/g, '
')}
+ +`; + const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = exportFilename(); + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (err: any) { + setError(err?.response?.data?.message || err?.message || t('audit.errors.export')); + } finally { + setExporting(false); + } + }; + + useEffect(() => { + let cancelled = false; + setItems([]); + setTotal(0); + setOffset(0); + setEventTypeOptions([]); + setHasFlocksproCapability(false); + if (user?.role !== 'admin') { + setCheckingCapability(false); + return () => { + cancelled = true; + }; + } + + setCheckingCapability(true); + void flocksproUsersApi.hasCapability() + .then((ok) => { + if (cancelled) return; + setHasFlocksproCapability(ok); + if (!ok) { + setError(t('audit.errors.unavailable')); + } + }) + .catch(() => { + if (cancelled) return; + setHasFlocksproCapability(false); + setError(t('audit.errors.unavailable')); + }) + .finally(() => { + if (!cancelled) { + setCheckingCapability(false); + } + }); + return () => { + cancelled = true; + }; + }, [t, user?.role]); + + useEffect(() => { + if (!hasFlocksproCapability) return; + void load(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hasFlocksproCapability]); + + useEffect(() => { + if (!hasFlocksproCapability) return; + let cancelled = false; + void flocksproAuditApi.listEventTypes() + .then((types) => { + if (!cancelled) { + setEventTypeOptions(types); + } + }) + .catch(() => { + if (!cancelled) { + setEventTypeOptions([]); + } + }); + return () => { + cancelled = true; + }; + }, [hasFlocksproCapability]); + + if (user?.role !== 'admin') { + return ( +
+

{t('audit.errors.forbidden')}

+
+ ); + } + + if (checkingCapability) { + return ( +
+

{t('audit.loading')}

+
+ ); + } + + if (!hasFlocksproCapability) { + return ( +
+

{t('audit.errors.unavailable')}

+
+ ); + } + + return ( +
+ } + /> + +
+
+ + setActorId(e.target.value)} + placeholder={t('audit.filters.actor')} + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + setStartAt(e.target.value)} + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + setEndAt(e.target.value)} + className="rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> +
+ +
+ + +
+ + {t('audit.total', { total })} + + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + + + + + + + + + + {items.length === 0 && ( + + + + )} + {items.map((item) => { + const expanded = expandedRowId === item.id; + const actor = actorLabel(item); + const resource = item.resource_type ? `${item.resource_type}:${item.resource_id || '-'}` : '-'; + const payload = payloadPreview(item); + + return ( + setExpandedRowId(expanded ? null : item.id)} + className="border-b border-gray-100 align-top cursor-pointer hover:bg-slate-50" + > + + + + + + + + ); + })} + +
{t('audit.table.time')}{t('audit.table.eventType')}{t('audit.table.actor')}{t('audit.table.resource')}{t('audit.table.result')}{t('audit.table.payload')}
+ {loading ? t('audit.loading') : t('audit.empty')} +
{formatLocalTime(item.created_at)} + {item.event_type} + + {actor} + + {resource} + {item.result || item.status} + {payload} +
+
+ +
+ {t('audit.page', { page, pageCount })} +
+ + +
+
+
+
+ ); +} diff --git a/webui/src/pages/FlocksproUpgrade/Callback.tsx b/webui/src/pages/FlocksproUpgrade/Callback.tsx new file mode 100644 index 000000000..035e0cd45 --- /dev/null +++ b/webui/src/pages/FlocksproUpgrade/Callback.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { authApi } from '@/api/auth'; +import { extractErrorMessage } from '@/utils/error'; + +export default function FlocksproUpgradeCallbackPage() { + const { t } = useTranslation('flockspro'); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [error, setError] = useState(null); + + useEffect(() => { + const consoleLoginId = searchParams.get('console_login_id'); + const state = searchParams.get('state') ?? undefined; + const passportUid = searchParams.get('passport_uid') ?? undefined; + const loginStatus = searchParams.get('console_login_status'); + const callbackMessage = searchParams.get('message') || searchParams.get('error_code'); + if (loginStatus === 'error') { + setError(callbackMessage || t('callback.exchangeFailed')); + return; + } + if (!consoleLoginId) { + setError(t('callback.missingConsoleLoginId')); + return; + } + + let cancelled = false; + const run = async () => { + try { + await authApi.finishConsoleLogin(consoleLoginId, state, passportUid); + if (!cancelled) { + navigate('/flockspro-upgrade?login=success', { replace: true }); + } + } catch (err) { + if (!cancelled) { + setError(extractErrorMessage(err, t('callback.exchangeFailed'))); + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [navigate, searchParams, t]); + + if (error) { + return ( +
+

{t('callback.failedTitle')}

+

{error}

+
+ ); + } + + return ( +
+
+ + {t('callback.processing')} +
+
+ ); +} + diff --git a/webui/src/pages/FlocksproUpgrade/index.tsx b/webui/src/pages/FlocksproUpgrade/index.tsx new file mode 100644 index 000000000..598103ede --- /dev/null +++ b/webui/src/pages/FlocksproUpgrade/index.tsx @@ -0,0 +1,1293 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowUpCircle, CheckCircle, ChevronDown, Loader2, LogIn, X, XCircle } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '@/components/common/PageHeader'; +import { authApi, type ConsoleLoginSessionStatus } from '@/api/auth'; +import client from '@/api/client'; +import { + consoleUpgradeApi, + type ProPackageStatus, + type UpgradeRequestCreatePayload, + type UpgradeRequestStatus, +} from '@/api/consoleUpgrade'; +import { type UpdateProgress } from '@/api/update'; +import { extractErrorMessage } from '@/utils/error'; + +interface UpgradeApplyFormState { + product: string; + licenseType: 'trial_30d' | 'poc' | 'commercial'; + company: string; + applicantName: string; + applicantEmail: string; + applicantPhone: string; + notes: string; +} + +interface FlocksproLicenseStatus { + activated: boolean; + active: boolean; + license_id?: string | null; + status?: string | null; + license_status?: string | null; + inactive_reason?: string | null; + reapply_allowed?: boolean | null; + expires_at?: number | string | null; + max_admins?: number | null; + max_members?: number | null; + fingerprint?: string | null; + install_id?: string | null; + [key: string]: string | number | boolean | null | undefined; +} + +const DEFAULT_FORM: UpgradeApplyFormState = { + product: 'Flocks Pro', + licenseType: 'trial_30d', + company: '', + applicantName: '', + applicantEmail: '', + applicantPhone: '', + notes: '', +}; + +const UPGRADE_PAGE_MARKER = 'flocks-upgrade-in-progress'; +const DISMISSED_REJECTED_REQUESTS_KEY = 'flockspro-dismissed-rejected-requests'; +const HEALTH_POLL_INTERVAL = 2000; +const HEALTH_POLL_TIMEOUT = 5 * 60 * 1000; + +function loadDismissedRejectedRequestIds(): Set { + if (typeof window === 'undefined') { + return new Set(); + } + try { + const raw = window.localStorage.getItem(DISMISSED_REJECTED_REQUESTS_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)); + } catch { + return new Set(); + } +} + +function saveDismissedRejectedRequestIds(ids: Set): void { + if (typeof window === 'undefined') { + return; + } + try { + window.localStorage.setItem(DISMISSED_REJECTED_REQUESTS_KEY, JSON.stringify([...ids].slice(-200))); + } catch { + // Ignore storage failures; the dismissal still applies for the current page session. + } +} + +async function getFlocksproLicenseStatus(): Promise { + const response = await client.get('/api/flockspro/license/status'); + return response.data; +} + +async function refreshFlocksproLicenseStatus(): Promise { + await client.post('/api/flockspro/license/refresh').catch(() => undefined); + return getFlocksproLicenseStatus(); +} + +function formatProVersion(version?: string | null): string { + const normalized = (version || '').trim().replace(/^pro-v/i, '').replace(/^v/i, ''); + return normalized ? `pro-v${normalized}` : 'pro-v...'; +} + +function formatDateTimeValue(value?: string | number | null): string { + if (value === null || value === undefined || value === '') { + return '-'; + } + const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value); + if (Number.isNaN(d.getTime())) { + return String(value); + } + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function daysRemaining(value?: string | number | null): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + const d = typeof value === 'number' ? new Date(value * 1000) : new Date(value); + if (Number.isNaN(d.getTime())) { + return null; + } + return Math.max(0, Math.ceil((d.getTime() - Date.now()) / 86400000)); +} + +function formatLicenseValue(key: string, value: string | number | boolean | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '-'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (key.endsWith('_at') || key.endsWith('At')) { + return formatDateTimeValue(value); + } + return String(value); +} + +function compactIdentifier(value?: string | null, head = 10, tail = 8): string { + if (!value) { + return '-'; + } + if (value.length <= head + tail + 3) { + return value; + } + return `${value.slice(0, head)}...${value.slice(-tail)}`; +} + +function normalizeLicenseType(value?: string | null): 'trial' | 'test' | 'commercial' | null { + const normalized = String(value || '').trim().toLowerCase(); + if (['trial', 'trial_30d'].includes(normalized)) { + return 'trial'; + } + if (['test', 'poc'].includes(normalized)) { + return 'test'; + } + if (normalized === 'commercial') { + return 'commercial'; + } + return null; +} + +function requestAccountKey(item: UpgradeRequestStatus): string { + return String( + item.details?.console_account_name || + item.details?.cloud_account || + item.details?.passport_uid || + item.details?.account || + '', + ).trim().toLowerCase(); +} + +function isRequestForCurrentAccount(item: UpgradeRequestStatus, currentAccountKey: string): boolean { + if (!currentAccountKey) { + return true; + } + const accountKey = requestAccountKey(item); + return !accountKey || accountKey === currentAccountKey; +} + +function requestLicenseId(item: UpgradeRequestStatus): string { + return String(item.license_id || item.details?.license_id || item.activate_key || item.request_id || '-'); +} + +function requestHasIssuedLicense(item: UpgradeRequestStatus): boolean { + return Boolean(item.license_id || item.details?.license_id || item.activate_key); +} + +function requestExpiresAt(item: UpgradeRequestStatus): string | number | null | undefined { + return item.expires_at || item.details?.license_effective_expires_at || item.details?.expires_at; +} + +function requestLicenseStatus(item: UpgradeRequestStatus): string { + return ( + item.license_status || + item.details?.license_status || + normalizeLicenseType(item.details?.license_type)?.toString() || + item.status || + '-' + ); +} + +function isInactiveLicenseStatus(value?: string | null): boolean { + return ['revoked', 'expired', 'superseded'].includes(String(value || '').trim().toLowerCase()); +} + +function requestMaxAdmins(item: UpgradeRequestStatus): number | null | undefined { + return item.max_admins ?? (typeof item.details?.max_admins === 'number' ? item.details.max_admins : null); +} + +function requestMaxMembers(item: UpgradeRequestStatus): number | null | undefined { + return item.max_members ?? (typeof item.details?.max_members === 'number' ? item.details.max_members : null); +} + +function requestCreatedTime(item: UpgradeRequestStatus): number { + const created = new Date(item.created_at || item.updated_at).getTime(); + return Number.isNaN(created) ? 0 : created; +} + +function requestDurationDays(item: UpgradeRequestStatus): number | null { + if (typeof item.details?.license_duration_days === 'number') { + return item.details.license_duration_days; + } + const expiresAt = requestExpiresAt(item); + const createdAt = new Date(item.created_at); + const expiresDate = + typeof expiresAt === 'number' ? new Date(expiresAt * 1000) : expiresAt ? new Date(expiresAt) : null; + if (!expiresDate || Number.isNaN(expiresDate.getTime()) || Number.isNaN(createdAt.getTime())) { + return null; + } + return Math.max(1, Math.ceil((expiresDate.getTime() - createdAt.getTime()) / 86400000)); +} + +export default function FlocksproUpgradePage() { + const { t } = useTranslation('flockspro'); + const [searchParams, setSearchParams] = useSearchParams(); + const [consoleLoginStatus, setConsoleLoginStatus] = useState(null); + const [consoleLoginLoading, setConsoleLoginLoading] = useState(false); + const [consoleLoginError, setConsoleLoginError] = useState(null); + const [consoleLoginSuccess, setConsoleLoginSuccess] = useState(null); + const [requests, setRequests] = useState([]); + const [requestError, setRequestError] = useState(null); + const [activeRequestId, setActiveRequestId] = useState(null); + const [showApplyDialog, setShowApplyDialog] = useState(false); + const [submittingApply, setSubmittingApply] = useState(false); + const [applyForm, setApplyForm] = useState(DEFAULT_FORM); + const [applyFormError, setApplyFormError] = useState(null); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const [upgradeSteps, setUpgradeSteps] = useState([]); + const [upgradeError, setUpgradeError] = useState(null); + const [proUpgrading, setProUpgrading] = useState(false); + const [proRestarting, setProRestarting] = useState(false); + const [refreshingInstalled, setRefreshingInstalled] = useState(false); + const [showLicenseDetails, setShowLicenseDetails] = useState(false); + const [licenseStatus, setLicenseStatus] = useState(null); + const [proPackageStatus, setProPackageStatus] = useState(null); + const [dismissedRejectedRequestIds, setDismissedRejectedRequestIds] = useState>( + loadDismissedRejectedRequestIds, + ); + const consoleAccountName = consoleLoginStatus?.account_name?.trim() ?? ''; + const currentConsoleAccountKey = consoleLoginStatus?.logged_in ? consoleAccountName.toLowerCase() : ''; + const isProPackageInstalled = proPackageStatus?.installed === true; + const licenseReapplyAllowed = + licenseStatus?.reapply_allowed === true || + ['revoked', 'expired'].includes(String(licenseStatus?.license_status || '').toLowerCase()); + const runtimeLicenseInvalid = + licenseReapplyAllowed || + licenseStatus?.active === false || + isInactiveLicenseStatus(licenseStatus?.license_status); + const invalidRuntimeLicenseId = + runtimeLicenseInvalid && licenseStatus?.license_id ? String(licenseStatus.license_id) : ''; + const accountScopedRequests = useMemo( + () => requests.filter((item) => isRequestForCurrentAccount(item, currentConsoleAccountKey)), + [currentConsoleAccountKey, requests], + ); + + const visibleRequests = useMemo( + () => { + const currentStatuses = ['pending', 'reviewing', 'approved']; + return accountScopedRequests.filter((item) => { + const status = (item.status || '').toLowerCase(); + if (status === 'rejected') { + return !dismissedRejectedRequestIds.has(item.request_id); + } + if (status === 'approved') { + return !requestHasIssuedLicense(item) || !isProPackageInstalled; + } + return currentStatuses.includes(status); + }); + }, + [accountScopedRequests, dismissedRejectedRequestIds, isProPackageInstalled], + ); + + const activeRequest = useMemo( + () => + visibleRequests.find((item) => item.request_id === activeRequestId) ?? visibleRequests[0] ?? null, + [activeRequestId, visibleRequests], + ); + + const currentIssuedRequest = useMemo( + () => { + const issued = accountScopedRequests.filter((item) => { + const status = (item.status || '').toLowerCase(); + return ['approved', 'activated'].includes(status) && requestHasIssuedLicense(item); + }); + issued.sort((a, b) => requestCreatedTime(b) - requestCreatedTime(a)); + return issued[0] ?? null; + }, + [accountScopedRequests], + ); + + const latestActivatedRequest = useMemo( + () => + requests.find((item) => { + const status = (item.status || '').toLowerCase(); + const installResult = (item.details?.auto_install_result || '').toLowerCase(); + return status === 'activated' || ['done', 'already_latest', 'restarting'].includes(installResult); + }) ?? null, + [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 || proPackageStatus?.pro_enabled === true; + const canUseProFeatures = isProPackageInstalled && isProRuntimeActive; + const isProLoaded = canUseProFeatures; + const hasRuntimeLicense = Boolean(licenseStatus?.license_id); + const runtimeLicenseUsable = hasRuntimeLicense && !runtimeLicenseInvalid; + const preferRequestLicense = + Boolean(currentIssuedRequest) && + (!runtimeLicenseUsable || + requestLicenseId(currentIssuedRequest as UpgradeRequestStatus) !== licenseStatus?.license_id); + const currentDisplayLicenseId = preferRequestLicense + ? requestLicenseId(currentIssuedRequest as UpgradeRequestStatus) + : runtimeLicenseUsable + ? licenseStatus?.license_id + : undefined; + const showCurrentLicenseCard = Boolean(currentDisplayLicenseId); + const displayedLicenseStatus = (preferRequestLicense + ? requestLicenseStatus(currentIssuedRequest as UpgradeRequestStatus) + : licenseStatus?.license_status) || + licenseStatus?.status || + '-'; + const displayedLicenseInactive = isInactiveLicenseStatus(String(displayedLicenseStatus)); + const currentLicenseInvalid = + Boolean(currentDisplayLicenseId) && (displayedLicenseInactive || (!preferRequestLicense && runtimeLicenseInvalid)); + const displayedExpiresAt = + (preferRequestLicense && currentIssuedRequest ? requestExpiresAt(currentIssuedRequest) : licenseStatus?.expires_at) || + (!preferRequestLicense + ? latestActivatedRequest?.details?.license_effective_expires_at || latestActivatedRequest?.details?.expires_at + : undefined); + const remainingDays = daysRemaining(displayedExpiresAt); + const displayedMaxAdmins = preferRequestLicense && currentIssuedRequest + ? requestMaxAdmins(currentIssuedRequest) + : licenseStatus?.max_admins; + const displayedMaxMembers = preferRequestLicense && currentIssuedRequest + ? requestMaxMembers(currentIssuedRequest) + : licenseStatus?.max_members; + const displayedLastSyncedAt = + preferRequestLicense && currentIssuedRequest ? currentIssuedRequest.updated_at : latestActivatedRequest?.updated_at; + const licenseQuotaText = [ + displayedMaxAdmins + ? t('upgrade.adminQuotaValue', { count: displayedMaxAdmins }) + : null, + displayedMaxMembers + ? t('upgrade.memberQuotaValue', { count: displayedMaxMembers }) + : null, + ].filter(Boolean).join(' / ') || '-'; + const licenseDetailRows = useMemo(() => { + if (!licenseStatus && !currentDisplayLicenseId) { + return []; + } + return [ + ['license_id', currentDisplayLicenseId], + ['install_id', preferRequestLicense ? undefined : licenseStatus?.install_id], + ['fingerprint', preferRequestLicense ? undefined : licenseStatus?.fingerprint], + ] + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => ({ + key: String(key), + label: t(`upgrade.licenseFieldLabels.${key}`), + value: formatLicenseValue(String(key), value), + })); + }, [currentDisplayLicenseId, licenseStatus, preferRequestLicense, t]); + + const refreshConsoleLoginStatus = useCallback(async () => { + setConsoleLoginLoading(true); + setConsoleLoginError(null); + try { + const data = await authApi.consoleLoginSession(); + setConsoleLoginStatus(data); + } catch (err) { + setConsoleLoginError(extractErrorMessage(err, t('errors.fetchConsoleLoginStatus'))); + } finally { + setConsoleLoginLoading(false); + } + }, [t]); + + const refreshRequests = useCallback(async () => { + setRequestError(null); + try { + const data = await consoleUpgradeApi.listRequests(); + setRequests(data); + const currentStatuses = ['pending', 'reviewing', 'approved']; + const nextVisible = data.filter((item) => { + if (!isRequestForCurrentAccount(item, currentConsoleAccountKey)) { + return false; + } + const status = (item.status || '').toLowerCase(); + if (status === 'rejected') { + return !dismissedRejectedRequestIds.has(item.request_id); + } + return currentStatuses.includes(status); + }); + setActiveRequestId((prev) => { + if (prev && nextVisible.some((item) => item.request_id === prev)) { + return prev; + } + return nextVisible[0]?.request_id ?? null; + }); + } catch (err) { + setRequestError(extractErrorMessage(err, t('errors.fetchRequests'))); + } + }, [currentConsoleAccountKey, dismissedRejectedRequestIds, t]); + + useEffect(() => { + if (!activeRequestId) { + return; + } + if (!visibleRequests.some((item) => item.request_id === activeRequestId)) { + setActiveRequestId(visibleRequests[0]?.request_id ?? null); + } + }, [activeRequestId, visibleRequests]); + + useEffect(() => { + void refreshConsoleLoginStatus(); + void refreshRequests(); + }, [refreshConsoleLoginStatus, refreshRequests]); + + useEffect(() => { + let cancelled = false; + void refreshFlocksproLicenseStatus() + .then((status) => { + if (!cancelled) { + setLicenseStatus(status); + } + }) + .catch(() => { + if (!cancelled) { + setLicenseStatus(null); + } + }); + void consoleUpgradeApi.getProPackageStatus() + .then((status) => { + if (!cancelled) { + setProPackageStatus(status); + } + }) + .catch(() => { + if (!cancelled) { + setProPackageStatus(null); + } + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const loginResult = searchParams.get('login'); + const consoleLoginStatusParam = searchParams.get('console_login_status'); + const consoleLoginId = searchParams.get('console_login_id'); + const state = searchParams.get('state') ?? undefined; + const passportUid = searchParams.get('passport_uid') ?? undefined; + if (!loginResult && !consoleLoginStatusParam) { + return; + } + let cancelled = false; + const finalize = async () => { + try { + if (loginResult === 'success') { + await refreshConsoleLoginStatus(); + } else if (consoleLoginStatusParam === 'success' && consoleLoginId) { + await authApi.finishConsoleLogin(consoleLoginId, state, passportUid); + await refreshConsoleLoginStatus(); + } + } catch (err) { + if (!cancelled) { + setConsoleLoginError(extractErrorMessage(err, t('errors.finishConsoleLogin'))); + } + } finally { + if (!cancelled) { + const nextParams = new URLSearchParams(searchParams); + nextParams.delete('login'); + nextParams.delete('message'); + nextParams.delete('console_login_status'); + nextParams.delete('console_login_id'); + nextParams.delete('state'); + nextParams.delete('passport_uid'); + setSearchParams(nextParams, { replace: true }); + } + } + }; + void finalize(); + return () => { + cancelled = true; + }; + }, [refreshConsoleLoginStatus, searchParams, setSearchParams, t]); + + const startConsoleLogin = async () => { + setConsoleLoginError(null); + setConsoleLoginSuccess(null); + try { + const returnTo = `${window.location.origin}/flockspro-upgrade/callback`; + const result = await authApi.startConsoleLogin(returnTo); + window.location.href = result.passport_login_url; + } catch (err) { + setConsoleLoginError(extractErrorMessage(err, t('errors.startConsoleLogin'))); + } + }; + + const logoutConsoleLogin = async () => { + setConsoleLoginError(null); + setConsoleLoginSuccess(null); + try { + await authApi.logoutConsoleLogin(); + await refreshConsoleLoginStatus(); + } catch (err) { + setConsoleLoginError(extractErrorMessage(err, t('errors.logoutConsoleLogin'))); + } + }; + + const createUpgradeRequest = async () => { + const company = applyForm.company.trim(); + const applicantName = applyForm.applicantName.trim(); + if (!company || !applicantName) { + setApplyFormError(t('upgrade.formRequiredError')); + return; + } + + setSubmittingApply(true); + setRequestError(null); + setApplyFormError(null); + try { + const payload: UpgradeRequestCreatePayload = { + product: applyForm.product, + license_type: applyForm.licenseType, + request_kind: 'new', + company, + applicant_name: applicantName, + applicant_email: applyForm.applicantEmail.trim() || undefined, + applicant_phone: applyForm.applicantPhone.trim() || undefined, + notes: applyForm.notes.trim() || undefined, + }; + const created = await consoleUpgradeApi.createRequest(payload); + setDismissedRejectedRequestIds((prev) => { + const next = new Set(prev); + requests + .filter((item) => (item.status || '').toLowerCase() === 'rejected') + .forEach((item) => next.add(item.request_id)); + saveDismissedRejectedRequestIds(next); + return next; + }); + setRequests((prev) => [created, ...prev]); + setActiveRequestId(created.request_id); + setShowApplyDialog(false); + setApplyForm(DEFAULT_FORM); + } catch (err) { + setApplyFormError(extractErrorMessage(err, t('errors.createRequest'))); + } finally { + setSubmittingApply(false); + } + }; + + const refreshActiveRequest = async () => { + if (!activeRequest) { + return; + } + try { + const latest = await consoleUpgradeApi.refreshRequest(activeRequest.request_id); + setRequests((prev) => + prev.map((item) => (item.request_id === latest.request_id ? latest : item)), + ); + } catch (err) { + setRequestError(extractErrorMessage(err, t('errors.refreshRequest'))); + } + }; + + const refreshInstalledStatus = async () => { + setRefreshingInstalled(true); + setRequestError(null); + try { + await consoleUpgradeApi.syncRevocations().catch(() => undefined); + await refreshRequests(); + const packageStatus = await consoleUpgradeApi.getProPackageStatus(); + setProPackageStatus(packageStatus); + const status = await refreshFlocksproLicenseStatus(); + setLicenseStatus(status); + window.dispatchEvent(new Event('flockspro-license-status-changed')); + } catch (err) { + setRequestError(extractErrorMessage(err, t('errors.refreshRequest'))); + } finally { + setRefreshingInstalled(false); + } + }; + + const cancelActiveRequest = async () => { + if (!activeRequest) { + return; + } + try { + const latest = await consoleUpgradeApi.cancelRequest(activeRequest.request_id); + setRequests((prev) => + prev.map((item) => (item.request_id === latest.request_id ? latest : item)), + ); + } catch (err) { + setRequestError(extractErrorMessage(err, t('errors.cancelRequest'))); + } + }; + + const upsertUpgradeStep = (progress: UpdateProgress) => { + setUpgradeSteps((prev) => { + const existingIndex = prev.findIndex((item) => item.stage === progress.stage); + if (existingIndex === -1) { + return [...prev, progress]; + } + const next = [...prev]; + next[existingIndex] = progress; + return next; + }); + }; + + const pollUntilReady = () => { + const startedAt = Date.now(); + const poll = async () => { + if (Date.now() - startedAt > HEALTH_POLL_TIMEOUT) { + setUpgradeError(t('upgrade.restartTimeout')); + setProRestarting(false); + setProUpgrading(false); + return; + } + try { + const healthResponse = await fetch('/api/health', { cache: 'no-store' }); + if (healthResponse.ok) { + const rootResponse = await fetch('/', { cache: 'no-store' }); + const rootHtml = await rootResponse.text(); + const stillShowingUpgradePage = rootHtml.includes(UPGRADE_PAGE_MARKER); + if (rootResponse.ok && !stillShowingUpgradePage) { + window.location.assign(`${window.location.pathname}${window.location.search}`); + return; + } + } + } catch { + // Backend may be restarting. + } + setTimeout(() => { + void poll(); + }, HEALTH_POLL_INTERVAL); + }; + setTimeout(() => { + void poll(); + }, 1500); + }; + + const startProUpgrade = async () => { + if (!activeRequest) { + return; + } + setShowUpdateModal(true); + setProUpgrading(true); + setProRestarting(false); + setUpgradeError(null); + setUpgradeSteps([]); + let sawRestarting = false; + try { + await consoleUpgradeApi.startRequest(activeRequest.request_id, (progress) => { + upsertUpgradeStep(progress); + if (progress.stage === 'restarting') { + sawRestarting = true; + setProUpgrading(false); + setProRestarting(true); + pollUntilReady(); + } + }); + if (!sawRestarting) { + setProUpgrading(false); + await refreshRequests(); + const packageStatus = await consoleUpgradeApi.getProPackageStatus(); + setProPackageStatus(packageStatus); + const status = await refreshFlocksproLicenseStatus(); + setLicenseStatus(status); + window.dispatchEvent(new Event('flockspro-license-status-changed')); + } + } catch (err) { + if (!sawRestarting) { + setUpgradeError(extractErrorMessage(err, t('errors.startUpgrade'))); + setProUpgrading(false); + } + } + }; + + const canApplyUpgrade = consoleLoginStatus?.logged_in === true; + const hasOpenRequest = accountScopedRequests.some((item) => { + const status = (item.status || '').toLowerCase(); + if (['pending', 'reviewing'].includes(status)) { + return true; + } + return status === 'approved' && (!requestHasIssuedLicense(item) || !isProPackageInstalled); + }); + const canOpenApplyDialog = canApplyUpgrade && !hasOpenRequest; + const showApprovedActions = activeRequest?.status === 'approved' && !isProPackageInstalled; + const showRejectedFeedback = activeRequest?.status === 'rejected'; + const canCancel = + activeRequest?.status === 'pending' || + activeRequest?.status === 'reviewing' || + activeRequest?.status === 'approved'; + const primaryActionLabel = t('upgrade.applyNewLicenseAction'); + const activeRequestIsCurrentLicense = + Boolean(activeRequest && currentIssuedRequest) && + activeRequest?.request_id === currentIssuedRequest?.request_id && + showCurrentLicenseCard; + const showActiveRequestCard = Boolean(activeRequest) && !(activeRequestIsCurrentLicense && isProPackageInstalled); + const historyRequests = accountScopedRequests.filter((item) => { + if (item.request_id === currentIssuedRequest?.request_id) { + return false; + } + if (showActiveRequestCard && item.request_id === activeRequest?.request_id) { + return false; + } + return true; + }); + + const dismissRejectedRequest = (requestId: string) => { + setDismissedRejectedRequestIds((prev) => { + const next = new Set(prev); + next.add(requestId); + saveDismissedRejectedRequestIds(next); + return next; + }); + setActiveRequestId((prev) => (prev === requestId ? null : prev)); + }; + + const formatDateTime = (value?: string | null): string => { + return formatDateTimeValue(value); + }; + + return ( +
+ } + /> + +
+

{t('consoleLogin.title')}

+
+
+
+ {t('consoleLogin.accountLabel')} + + {consoleLoginLoading + ? t('consoleLogin.loading') + : consoleLoginStatus?.logged_in + ? consoleAccountName + : t('consoleLogin.unbound')} + +
+ {consoleLoginStatus?.logged_in ? ( +
+ +
+ ) : ( + + )} +
+
+ + {consoleLoginError && ( +
+ {consoleLoginError} +
+ )} + {consoleLoginSuccess && ( +
+ {consoleLoginSuccess} +
+ )} +
+ +
+
+
+

+ {isProLoaded ? t('upgrade.installedTitle', { version: proVersion }) : t('upgrade.title')} +

+

+ {isProLoaded ? t('upgrade.installedDescription') : t('upgrade.description')} +

+
+ {isProLoaded ? ( +
+ + {!showCurrentLicenseCard && ( + + )} +
+ ) : ( + + )} +
+ + {!canApplyUpgrade && ( +
+ {t('upgrade.loginFirst')} +
+ )} + {requestError && ( +
+ {requestError} +
+ )} + + {showActiveRequestCard && activeRequest ? ( +
+
+
{t('upgrade.currentRequest')}
+
+
{activeRequest.request_id}
+ {showRejectedFeedback && ( + + )} +
+
+
+
{t('upgrade.status')}
+
+ {t(`upgrade.statusLabels.${activeRequest.status}`, { defaultValue: activeRequest.status })} +
+
+
+
{t('upgrade.updatedAt')}
+
{formatDateTime(activeRequest.updated_at)}
+
+ {showRejectedFeedback && ( +
+
{t('upgrade.rejectedTitle')}
+ {activeRequest.reason &&
{activeRequest.reason}
} + {activeRequest.suggestion &&
{activeRequest.suggestion}
} +
+ )} + {!showRejectedFeedback && activeRequest.suggestion && ( +
+ {activeRequest.suggestion} +
+ )} +
+ + {canCancel && ( + + )} + {showApprovedActions && ( + + )} +
+ {showApprovedActions && ( +
+ {t('upgrade.afterUpgradeHint')} +
+ )} +
+ ) : !isProLoaded && !showCurrentLicenseCard ? ( +
+ {t('upgrade.noRequest')} +
+ ) : ( + null + )} + + {showCurrentLicenseCard && ( +
+ {currentLicenseInvalid && ( +
+ {t('upgrade.revokedOrExpiredHint')} +
+ )} +
+
+
+ {proVersion} + + {t(`upgrade.licenseStatusLabels.${displayedLicenseStatus}`, { defaultValue: displayedLicenseStatus })} + +
+
+ {t('upgrade.licenseId')}: {compactIdentifier(currentDisplayLicenseId)} +
+
+ +
+
+
+
{t('upgrade.remainingDays')}
+
+ {remainingDays === null ? '-' : t('upgrade.remainingDaysValue', { count: remainingDays })} +
+
+
+
{t('upgrade.quota')}
+
{licenseQuotaText}
+
+
+
{t('upgrade.expiresAt')}
+
{formatDateTimeValue(displayedExpiresAt)}
+
+
+
{t('upgrade.lastSyncedAt')}
+
+ {formatDateTimeValue(displayedLastSyncedAt)} +
+
+
+ {licenseDetailRows.length > 0 && ( +
+ + {showLicenseDetails && ( +
+ {licenseDetailRows.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ )} +
+ )} +
+ )} + + {historyRequests.length > 0 && ( +
+
+ {t('upgrade.licenseHistory')} +
+
+ + + + {[ + t('upgrade.historyColumns.licenseId'), + t('upgrade.historyColumns.licenseType'), + t('upgrade.historyColumns.status'), + t('upgrade.historyColumns.appliedAt'), + t('upgrade.historyColumns.expiresAt'), + t('upgrade.historyColumns.durationDays'), + t('upgrade.historyColumns.account'), + ].map((header) => ( + + ))} + + + + {historyRequests.map((item) => { + const expiresAt = requestExpiresAt(item); + const duration = requestDurationDays(item); + const account = requestAccountKey(item) || '-'; + const itemLicenseId = requestLicenseId(item); + const historyLicenseType = + normalizeLicenseType(item.details?.license_type) || + normalizeLicenseType(item.license_status) || + normalizeLicenseType(item.details?.license_status); + const rawLicenseStatus = item.license_status || item.details?.license_status || ''; + const licenseStatusIsType = Boolean(normalizeLicenseType(rawLicenseStatus)); + const historyStatus = itemLicenseId === invalidRuntimeLicenseId + ? 'revoked' + : licenseStatusIsType + ? item.status + : rawLicenseStatus || item.status; + const historyStatusLabelGroup = + isInactiveLicenseStatus(historyStatus) || historyStatus === 'active' + ? 'licenseStatusLabels' + : 'statusLabels'; + return ( + + + + + + + + + + ); + })} + +
+ {header} +
{compactIdentifier(itemLicenseId)} + {historyLicenseType + ? t(`upgrade.licenseTypeLabels.${historyLicenseType}`, { defaultValue: historyLicenseType }) + : '-'} + + {t(`upgrade.${historyStatusLabelGroup}.${historyStatus}`, { defaultValue: historyStatus })} + {formatDateTime(item.created_at)}{formatDateTimeValue(expiresAt)}{duration ?? '-'}{account}
+
+
+ )} + +
+ + {showApplyDialog && ( +
+
+

{t('upgrade.applyDialogTitle')}

+
+
+
{t('upgrade.productLabel')}
+ +
+
+
{t('upgrade.licenseTypeLabel')}
+ +
+ setApplyForm((prev) => ({ ...prev, company: event.target.value }))} + placeholder={t('upgrade.companyPlaceholderRequired')} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200" + /> + setApplyForm((prev) => ({ ...prev, applicantName: event.target.value }))} + placeholder={t('upgrade.applicantNamePlaceholderRequired')} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200" + /> + setApplyForm((prev) => ({ ...prev, applicantEmail: event.target.value }))} + placeholder={t('upgrade.applicantEmailPlaceholder')} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200" + /> + setApplyForm((prev) => ({ ...prev, applicantPhone: event.target.value }))} + placeholder={t('upgrade.applicantPhonePlaceholder')} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-200" + /> +