From 55bf3382319c3a29d4e398c23a79b568b06dfe06 Mon Sep 17 00:00:00 2001 From: willxue Date: Mon, 23 Mar 2026 16:11:19 +0800 Subject: [PATCH 1/3] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 45c1abc..669d6cf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ # misc .DS_Store *.pem +.worktrees/ # debug npm-debug.log* From 70d746a2316955e5eef8dc6d61f8c7225e84775a Mon Sep 17 00:00:00 2001 From: willxue Date: Wed, 25 Mar 2026 11:14:34 +0800 Subject: [PATCH 2/3] feat: bootstrap mango v1 workspace --- .editorconfig | 12 + .githooks/commit-msg | 4 + .githooks/pre-commit | 7 + .github/ISSUE_TEMPLATE/bug-report.yml | 34 + .github/ISSUE_TEMPLATE/feature-request.yml | 21 + .github/PULL_REQUEST_TEMPLATE.md | 35 + .github/workflows/ci.yml | 61 + .github/workflows/pr-title.yml | 25 + .gitignore | 14 + .prettierignore | 7 + .prettierrc.json | 11 + CHANGELOG.md | 45 + CONTRIBUTING.md | 99 + PLAN.md | 298 + README.md | 112 +- SECURITY.md | 37 + apps/api/README.md | 6 + apps/desktop/electron-builder.yml | 19 + apps/desktop/electron.vite.config.ts | 40 + apps/desktop/package.json | 32 + .../src/main/app/bootstrapDesktopApp.ts | 74 + apps/desktop/src/main/index.ts | 3 + .../main/ipc/registerDesktopIpcHandlers.ts | 19 + .../main/persistence/fileDesktopStore.test.ts | 120 + .../src/main/persistence/fileDesktopStore.ts | 98 + ...create_desktop_runtime_tables.rollback.sql | 12 + .../0001_create_desktop_runtime_tables.sql | 63 + .../persistence/sqliteDesktopStore.test.ts | 134 + .../main/persistence/sqliteDesktopStore.ts | 520 + .../sqliteMigrationCatalog.test.ts | 31 + .../persistence/sqliteMigrationCatalog.ts | 23 + .../main/services/desktopController.test.ts | 153 + .../src/main/services/desktopController.ts | 333 + apps/desktop/src/preload/api/desktopApi.ts | 11 + apps/desktop/src/preload/index.ts | 5 + apps/desktop/src/renderer/index.html | 12 + apps/desktop/src/renderer/src/app/App.tsx | 121 + apps/desktop/src/renderer/src/app/fixtures.ts | 138 + .../src/renderer/src/components/README.md | 7 + .../features/task-workbench/TaskWorkbench.tsx | 266 + apps/desktop/src/renderer/src/hooks/README.md | 7 + .../src/renderer/src/lib/desktopApi.ts | 13 + apps/desktop/src/renderer/src/main.tsx | 15 + apps/desktop/src/renderer/src/pages/README.md | 7 + .../desktop/src/renderer/src/styles/index.css | 441 + .../src/renderer/src/test/App.test.tsx | 81 + apps/desktop/src/renderer/src/test/setup.ts | 1 + apps/desktop/src/renderer/src/vite-env.d.ts | 9 + apps/desktop/tsconfig.json | 14 + apps/desktop/vitest.config.ts | 3 + apps/desktop/vitest.main.config.ts | 19 + apps/web/README.md | 6 + apps/worker/README.md | 6 + commitlint.config.mjs | 19 + docs/README.md | 191 + docs/design/brand-and-ux-guidelines.md | 63 + docs/design/interaction-spec.md | 88 + .../api-and-database-naming-standards.md | 250 + .../backend-development-standards.md | 106 + .../ci-cd-and-automation-standards.md | 89 + .../code-style-and-naming-standards.md | 415 + .../desktop-backend-development-standards.md | 131 + docs/engineering/detailed-architecture.md | 622 ++ docs/engineering/development-workflow.md | 92 + .../engineering-standards-overview.md | 137 + .../frontend-development-standards.md | 144 + .../interface-and-contract-standards.md | 101 + docs/engineering/module-contracts.md | 128 + .../monorepo-and-directory-standards.md | 169 + docs/engineering/openapi-standards.md | 202 + .../security-and-permission-standards.md | 65 + .../engineering/sqlite-migration-standards.md | 207 + docs/engineering/storage-and-security.md | 104 + docs/engineering/technical-architecture.md | 134 + docs/engineering/templates-and-examples.md | 64 + docs/launch/checklist.md | 36 + docs/launch/operations-and-support.md | 50 + docs/launch/privacy.md | 39 + docs/launch/release-process.md | 60 + docs/launch/telemetry-policy.md | 39 + .../versioning-and-release-standards.md | 215 + .../mango-internal-architecture-report.pptx | Bin 0 -> 51863 bytes docs/process/document-governance.md | 53 + docs/process/pull-request-standards.md | 214 + docs/process/risk-and-decision-log.md | 53 + docs/process/source-control-standards.md | 200 + docs/product/competitive-analysis.md | 98 + docs/product/information-architecture.md | 94 + docs/product/personas-and-scenarios.md | 77 + docs/product/prd.md | 141 + docs/product/roadmap-and-milestones.md | 103 + docs/product/technical-design.md | 79 + docs/product/v1-backlog.md | 81 + docs/product/vision-and-positioning.md | 79 + docs/quality/acceptance-criteria.md | 43 + .../quality-gates-and-testing-standards.md | 102 + docs/quality/test-strategy.md | 64 + eslint.config.mjs | 5 + infra/docker/README.md | 3 + infra/github/README.md | 5 + infra/release/README.md | 3 + package.json | 64 + packages/adapters/package.json | 22 + packages/adapters/src/claudeCodeCliAdapter.ts | 298 + packages/adapters/src/index.ts | 12 + .../adapters/src/mockClaudeCodeAdapter.ts | 77 + .../tests/claudeCodeCliAdapter.test.ts | 127 + .../tests/mockClaudeCodeAdapter.test.ts | 46 + packages/adapters/tsconfig.build.json | 11 + packages/adapters/tsconfig.json | 8 + packages/adapters/vitest.config.ts | 3 + packages/config-eslint/base.mjs | 117 + packages/config-eslint/electron-processes.mjs | 17 + packages/config-eslint/package.json | 11 + packages/config-eslint/renderer.mjs | 31 + packages/config-playwright/base.mjs | 7 + packages/config-playwright/package.json | 9 + packages/config-typescript/backend-app.json | 6 + packages/config-typescript/base.json | 19 + packages/config-typescript/desktop-app.json | 7 + packages/config-typescript/library.json | 7 + packages/config-typescript/package.json | 11 + packages/config-vitest/desktop-renderer.d.mts | 3 + packages/config-vitest/desktop-renderer.mjs | 21 + packages/config-vitest/node.d.mts | 3 + packages/config-vitest/node.mjs | 17 + packages/config-vitest/package.json | 11 + .../config-vitest/workspace-aliases.d.mts | 1 + packages/config-vitest/workspace-aliases.mjs | 8 + packages/contracts/openapi/openapi.yaml | 121 + packages/contracts/package.json | 22 + packages/contracts/src/desktop.ts | 70 + packages/contracts/src/http.ts | 14 + packages/contracts/src/index.ts | 2 + packages/contracts/tests/contracts.test.ts | 26 + packages/contracts/tests/openapi.test.ts | 19 + packages/contracts/tsconfig.build.json | 11 + packages/contracts/tsconfig.json | 7 + packages/contracts/vitest.config.ts | 3 + packages/core/package.json | 19 + packages/core/src/index.ts | 4 + packages/core/src/permissionPolicy.ts | 44 + packages/core/src/taskSession.ts | 146 + packages/core/src/types.ts | 147 + packages/core/src/workspace.ts | 22 + packages/core/tests/permissionPolicy.test.ts | 34 + packages/core/tests/taskSession.test.ts | 108 + packages/core/tsconfig.build.json | 11 + packages/core/tsconfig.json | 7 + packages/core/vitest.config.ts | 3 + packages/ui/package.json | 19 + packages/ui/src/index.ts | 1 + packages/ui/src/tokens.ts | 23 + packages/ui/tests/tokens.test.ts | 9 + packages/ui/tsconfig.build.json | 11 + packages/ui/tsconfig.json | 7 + packages/ui/vitest.config.ts | 3 + pnpm-lock.yaml | 8702 +++++++++++++++++ pnpm-workspace.yaml | 7 + tests/contracts/README.md | 3 + tests/e2e/README.md | 3 + tests/fixtures/README.md | 3 + tooling/README.md | 7 + tooling/generators/README.md | 14 + .../generators/templates/api-module/README.md | 17 + .../templates/desktop-ipc-module/README.md | 15 + .../templates/openapi-spec/README.md | 23 + .../openapi-spec/openapi.template.yaml | 114 + .../templates/renderer-feature/README.md | 16 + .../0001_create_example_table.rollback.sql | 8 + .../0001_create_example_table.sql | 16 + .../templates/sqlite-migration/README.md | 15 + .../generators/templates/worker-job/README.md | 14 + tooling/scripts/README.md | 8 + tooling/scripts/check-architecture.mjs | 52 + tooling/scripts/check-branch-name.mjs | 29 + tooling/scripts/check-pr-title.mjs | 47 + tooling/scripts/clean-generated.mjs | 35 + tooling/scripts/desktop-smoke.mjs | 16 + .../generate_internal_architecture_ppt.py | 923 ++ tooling/scripts/install-git-hooks.mjs | 50 + tsconfig.base.json | 12 + tsconfig.json | 20 + turbo.json | 20 + 184 files changed, 21216 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .githooks/commit-msg create mode 100644 .githooks/pre-commit create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 PLAN.md create mode 100644 SECURITY.md create mode 100644 apps/api/README.md create mode 100644 apps/desktop/electron-builder.yml create mode 100644 apps/desktop/electron.vite.config.ts create mode 100644 apps/desktop/package.json create mode 100644 apps/desktop/src/main/app/bootstrapDesktopApp.ts create mode 100644 apps/desktop/src/main/index.ts create mode 100644 apps/desktop/src/main/ipc/registerDesktopIpcHandlers.ts create mode 100644 apps/desktop/src/main/persistence/fileDesktopStore.test.ts create mode 100644 apps/desktop/src/main/persistence/fileDesktopStore.ts create mode 100644 apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.rollback.sql create mode 100644 apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.sql create mode 100644 apps/desktop/src/main/persistence/sqliteDesktopStore.test.ts create mode 100644 apps/desktop/src/main/persistence/sqliteDesktopStore.ts create mode 100644 apps/desktop/src/main/persistence/sqliteMigrationCatalog.test.ts create mode 100644 apps/desktop/src/main/persistence/sqliteMigrationCatalog.ts create mode 100644 apps/desktop/src/main/services/desktopController.test.ts create mode 100644 apps/desktop/src/main/services/desktopController.ts create mode 100644 apps/desktop/src/preload/api/desktopApi.ts create mode 100644 apps/desktop/src/preload/index.ts create mode 100644 apps/desktop/src/renderer/index.html create mode 100644 apps/desktop/src/renderer/src/app/App.tsx create mode 100644 apps/desktop/src/renderer/src/app/fixtures.ts create mode 100644 apps/desktop/src/renderer/src/components/README.md create mode 100644 apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx create mode 100644 apps/desktop/src/renderer/src/hooks/README.md create mode 100644 apps/desktop/src/renderer/src/lib/desktopApi.ts create mode 100644 apps/desktop/src/renderer/src/main.tsx create mode 100644 apps/desktop/src/renderer/src/pages/README.md create mode 100644 apps/desktop/src/renderer/src/styles/index.css create mode 100644 apps/desktop/src/renderer/src/test/App.test.tsx create mode 100644 apps/desktop/src/renderer/src/test/setup.ts create mode 100644 apps/desktop/src/renderer/src/vite-env.d.ts create mode 100644 apps/desktop/tsconfig.json create mode 100644 apps/desktop/vitest.config.ts create mode 100644 apps/desktop/vitest.main.config.ts create mode 100644 apps/web/README.md create mode 100644 apps/worker/README.md create mode 100644 commitlint.config.mjs create mode 100644 docs/README.md create mode 100644 docs/design/brand-and-ux-guidelines.md create mode 100644 docs/design/interaction-spec.md create mode 100644 docs/engineering/api-and-database-naming-standards.md create mode 100644 docs/engineering/backend-development-standards.md create mode 100644 docs/engineering/ci-cd-and-automation-standards.md create mode 100644 docs/engineering/code-style-and-naming-standards.md create mode 100644 docs/engineering/desktop-backend-development-standards.md create mode 100644 docs/engineering/detailed-architecture.md create mode 100644 docs/engineering/development-workflow.md create mode 100644 docs/engineering/engineering-standards-overview.md create mode 100644 docs/engineering/frontend-development-standards.md create mode 100644 docs/engineering/interface-and-contract-standards.md create mode 100644 docs/engineering/module-contracts.md create mode 100644 docs/engineering/monorepo-and-directory-standards.md create mode 100644 docs/engineering/openapi-standards.md create mode 100644 docs/engineering/security-and-permission-standards.md create mode 100644 docs/engineering/sqlite-migration-standards.md create mode 100644 docs/engineering/storage-and-security.md create mode 100644 docs/engineering/technical-architecture.md create mode 100644 docs/engineering/templates-and-examples.md create mode 100644 docs/launch/checklist.md create mode 100644 docs/launch/operations-and-support.md create mode 100644 docs/launch/privacy.md create mode 100644 docs/launch/release-process.md create mode 100644 docs/launch/telemetry-policy.md create mode 100644 docs/launch/versioning-and-release-standards.md create mode 100644 docs/presentations/mango-internal-architecture-report.pptx create mode 100644 docs/process/document-governance.md create mode 100644 docs/process/pull-request-standards.md create mode 100644 docs/process/risk-and-decision-log.md create mode 100644 docs/process/source-control-standards.md create mode 100644 docs/product/competitive-analysis.md create mode 100644 docs/product/information-architecture.md create mode 100644 docs/product/personas-and-scenarios.md create mode 100644 docs/product/prd.md create mode 100644 docs/product/roadmap-and-milestones.md create mode 100644 docs/product/technical-design.md create mode 100644 docs/product/v1-backlog.md create mode 100644 docs/product/vision-and-positioning.md create mode 100644 docs/quality/acceptance-criteria.md create mode 100644 docs/quality/quality-gates-and-testing-standards.md create mode 100644 docs/quality/test-strategy.md create mode 100644 eslint.config.mjs create mode 100644 infra/docker/README.md create mode 100644 infra/github/README.md create mode 100644 infra/release/README.md create mode 100644 package.json create mode 100644 packages/adapters/package.json create mode 100644 packages/adapters/src/claudeCodeCliAdapter.ts create mode 100644 packages/adapters/src/index.ts create mode 100644 packages/adapters/src/mockClaudeCodeAdapter.ts create mode 100644 packages/adapters/tests/claudeCodeCliAdapter.test.ts create mode 100644 packages/adapters/tests/mockClaudeCodeAdapter.test.ts create mode 100644 packages/adapters/tsconfig.build.json create mode 100644 packages/adapters/tsconfig.json create mode 100644 packages/adapters/vitest.config.ts create mode 100644 packages/config-eslint/base.mjs create mode 100644 packages/config-eslint/electron-processes.mjs create mode 100644 packages/config-eslint/package.json create mode 100644 packages/config-eslint/renderer.mjs create mode 100644 packages/config-playwright/base.mjs create mode 100644 packages/config-playwright/package.json create mode 100644 packages/config-typescript/backend-app.json create mode 100644 packages/config-typescript/base.json create mode 100644 packages/config-typescript/desktop-app.json create mode 100644 packages/config-typescript/library.json create mode 100644 packages/config-typescript/package.json create mode 100644 packages/config-vitest/desktop-renderer.d.mts create mode 100644 packages/config-vitest/desktop-renderer.mjs create mode 100644 packages/config-vitest/node.d.mts create mode 100644 packages/config-vitest/node.mjs create mode 100644 packages/config-vitest/package.json create mode 100644 packages/config-vitest/workspace-aliases.d.mts create mode 100644 packages/config-vitest/workspace-aliases.mjs create mode 100644 packages/contracts/openapi/openapi.yaml create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/desktop.ts create mode 100644 packages/contracts/src/http.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/tests/contracts.test.ts create mode 100644 packages/contracts/tests/openapi.test.ts create mode 100644 packages/contracts/tsconfig.build.json create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/contracts/vitest.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/permissionPolicy.ts create mode 100644 packages/core/src/taskSession.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/src/workspace.ts create mode 100644 packages/core/tests/permissionPolicy.test.ts create mode 100644 packages/core/tests/taskSession.test.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/tokens.ts create mode 100644 packages/ui/tests/tokens.test.ts create mode 100644 packages/ui/tsconfig.build.json create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tests/contracts/README.md create mode 100644 tests/e2e/README.md create mode 100644 tests/fixtures/README.md create mode 100644 tooling/README.md create mode 100644 tooling/generators/README.md create mode 100644 tooling/generators/templates/api-module/README.md create mode 100644 tooling/generators/templates/desktop-ipc-module/README.md create mode 100644 tooling/generators/templates/openapi-spec/README.md create mode 100644 tooling/generators/templates/openapi-spec/openapi.template.yaml create mode 100644 tooling/generators/templates/renderer-feature/README.md create mode 100644 tooling/generators/templates/sqlite-migration/0001_create_example_table.rollback.sql create mode 100644 tooling/generators/templates/sqlite-migration/0001_create_example_table.sql create mode 100644 tooling/generators/templates/sqlite-migration/README.md create mode 100644 tooling/generators/templates/worker-job/README.md create mode 100644 tooling/scripts/README.md create mode 100644 tooling/scripts/check-architecture.mjs create mode 100644 tooling/scripts/check-branch-name.mjs create mode 100644 tooling/scripts/check-pr-title.mjs create mode 100644 tooling/scripts/clean-generated.mjs create mode 100644 tooling/scripts/desktop-smoke.mjs create mode 100644 tooling/scripts/generate_internal_architecture_ppt.py create mode 100644 tooling/scripts/install-git-hooks.mjs create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..dfe4849 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +pnpm exec commitlint --edit "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..bbee32b --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +pnpm branch:check +pnpm format:check +pnpm lint +pnpm architecture-check diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..06c969b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,34 @@ +name: 缺陷反馈 +description: 报告 Mango 中的错误流程、崩溃或异常行为。 +title: '[缺陷]: ' +labels: + - bug +body: + - type: textarea + id: summary + attributes: + label: 发生了什么? + description: 请描述实际发生的问题,以及你原本预期会发生什么。 + validations: + required: true + - type: input + id: version + attributes: + label: Mango 版本 + placeholder: 0.1.0-beta.1 + - type: dropdown + id: platform + attributes: + label: 平台 + options: + - Windows + - macOS + - Linux + - type: textarea + id: repro + attributes: + label: 复现步骤 + - type: textarea + id: logs + attributes: + label: 相关日志或截图 diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..56a8dc2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,21 @@ +name: 功能建议 +description: 为 Mango 提出新能力或工作流改进建议。 +title: '[功能建议]: ' +labels: + - enhancement +body: + - type: textarea + id: problem + attributes: + label: 当前卡住了什么工作流? + description: 请描述你正在做的事情,以及当前最明显的阻碍。 + validations: + required: true + - type: textarea + id: proposal + attributes: + label: 你希望 Mango 做什么? + - type: textarea + id: success + attributes: + label: 你认为什么结果算成功? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..75b2108 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## 背景 + + + +## 本次改动 + + + +## 不包含 + + + +## 契约 / 数据 / 权限影响 + + + +## 验证 + + + +## 风险与回滚 + + + +## 文档同步 + + + +## 自检清单 + +- [ ] 标题符合 Conventional Commits +- [ ] 已更新必要文档 +- [ ] 已说明契约 / 数据 / 权限影响 +- [ ] 已补齐验证方式 +- [ ] 已说明风险与回滚方式 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..887d2ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: + - main + - 'codex/**' + pull_request: + +jobs: + verify: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + - macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.6.5 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run format check + run: pnpm format:check + + - name: Run lint + run: pnpm lint + + - name: Run architecture check + run: pnpm architecture-check + + - name: Run tests + run: pnpm test + + - name: Run contract checks + run: pnpm check:contracts + + - name: Run typecheck + run: pnpm typecheck + + - name: Run build + run: pnpm build + + - name: Run desktop smoke + run: pnpm smoke:desktop diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..590dfb9 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,25 @@ +name: PR Title + +on: + pull_request: + types: + - opened + - edited + - reopened + - synchronize + +jobs: + validate-title: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Validate PR title + run: node tooling/scripts/check-pr-title.mjs "${{ github.event.pull_request.title }}" diff --git a/.gitignore b/.gitignore index 669d6cf..6c6f830 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ # dependencies /node_modules +**/node_modules /.pnp .pnp.js +.pnpm-store # testing /coverage +/test-results +/playwright-report # next.js /.next/ @@ -14,11 +18,21 @@ # production /build +/apps/desktop/dist +/apps/desktop/out +/apps/*/dist +/apps/*/out +/packages/*/dist +/packages/*/src/**/*.js +/packages/*/src/**/*.d.ts # misc .DS_Store *.pem .worktrees/ +/.turbo +**/.turbo +.eslintcache # debug npm-debug.log* diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c31b27b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.turbo +coverage +dist +out +playwright-report +test-results diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1ddb010 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf", + "printWidth": 100, + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d877203 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# 更新日志 + +本项目的所有重要变更都会记录在此文件中。 + +格式遵循 [Keep a Changelog 1.1.0](https://keepachangelog.com/zh-CN/1.1.0/),并尽量遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +### Added + +- 预留后续迭代的未发布变更记录区。 + +## [0.1.0] - 2026-03-25 + +### Added + +- 建立 `pnpm + Turborepo` 的 Mango Monorepo 工程底座,拆分 `apps/*`、`packages/*`、`tooling/*`、`tests/*`、`infra/*` 与 `docs/*`。 +- 建立 Mango v1 中文文档体系,覆盖产品定义、信息架构、技术架构、工程规范、测试策略、发布流程与协作流程。 +- 建立桌面端 `Electron + React + TypeScript` 应用骨架,落地 `Plan -> Approve -> Go -> Review` 主工作台界面。 +- 建立 `@mango/core`、`@mango/contracts`、`@mango/adapters`、`@mango/ui` 与 `@mango/config-*` 共享包。 +- 建立 `TaskSession`、`ExecutionEvent`、`WorkspaceContext`、`PermissionPolicy` 等领域模型与基础测试。 +- 建立 OpenAPI 单一事实源文件,作为未来 REST 契约与类型生成的基线。 +- 建立 SQLite migration 基线、迁移目录与桌面端持久化测试。 +- 新增 `Claude Code CLI` 适配器入口,支持 CLI 可用性检测、结构化计划生成与批准后执行摘要收口。 +- 新增桌面端适配器选择控件,允许用户看到当前执行适配器及其可用性说明。 +- 新增针对真实 CLI 适配器、桌面主进程编排、SQLite 存储与渲染工作台的测试覆盖。 + +### Changed + +- 桌面端本地持久化从早期 JSON 主存储升级为 `SQLite 主存储 + JSON 首次导入兜底`。 +- 桌面端 SQLite 运行时从实验性的 `node:sqlite` 切换为 `better-sqlite3`,消除实验性警告并稳定 native 依赖构建。 +- 桌面端主进程从写死的单一 mock adapter 改为可扩展的多 adapter 注册与选择机制。 +- Workspace 现在会持久化 `primaryAdapterId` 和 `configuredAdapters`,为后续任务恢复和多 provider 扩展打基础。 +- Renderer 不再盲选第一个 adapter,而是优先选择工作区上次使用且当前可用的 adapter。 + +### Fixed + +- 修复适配器执行失败时任务状态可能错误落为成功的问题,失败场景现在会被收口为 `failed` 并保留错误事件。 +- 修复桌面端类型检查未稳定指向 workspace 源码路径的问题,统一通过根级 `tsconfig.base.json` 路径别名约束桌面包类型检查。 +- 修复 SQLite 持久化层对 native SQLite 运行时的依赖不稳定问题,补齐 `pnpm onlyBuiltDependencies` 和类型声明。 +- 修复工作台对适配器状态可见性不足的问题,补充执行 adapter 选择与状态展示。 + +### Security + +- 保持高风险能力按 `PermissionPolicy` 显式评估与展示,为后续权限批准、失败恢复和审计记录预留统一边界。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9651c4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# 参与开发指南 + +本文档用于统一 Mango 项目的协作方式,避免“每个人都很努力,但方向越来越散”。 + +## 一、开始开发前必须完成的事情 + +在开始任何功能、重构、修复之前,先确认以下内容: + +1. 你读过 [docs/README.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/README.md) +2. 你明确这次改动对应哪一份需求或设计文档 +3. 你知道这次改动是否影响公共接口、交互流程、测试策略、发布流程 +4. 你知道完成后需要更新哪些文档 +5. 你知道需要遵守 [代码风格、命名与注释规范](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/code-style-and-naming-standards.md) + +如果以上任一项不明确,请先补文档或澄清目标,不要直接开写。 + +## 二、推荐研发节奏 + +推荐按以下顺序推进: + +1. 明确问题与目标 +2. 产出或更新文档 +3. 确认当前分支与提交策略符合 [分支与提交规范](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/process/source-control-standards.md) +4. 确认如果要提 PR,已经了解 [Pull Request 规范](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/process/pull-request-standards.md) +5. 拆解任务并确认边界 +6. 先写测试或验证方式 +7. 实现最小可行改动 +8. 运行 `pnpm verify` +9. 更新文档与变更记录 + +## 三、分支与提交建议 + +- 分支命名建议:`codex/`、`feature/`、`fix/` +- 提交信息尽量表达“为什么改”和“改了什么” +- 不要把多个无关目标混在一个提交里 +- PR 标题与正文请遵守 [Pull Request 规范](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/process/pull-request-standards.md) + +推荐提交前缀: + +- `feat:` 新功能 +- `fix:` 缺陷修复 +- `docs:` 文档更新 +- `refactor:` 重构 +- `test:` 测试补充 +- `chore:` 工程性调整 + +建议在提交前运行: + +- `pnpm hooks:install` +- `pnpm branch:check` +- `pnpm commitlint:recent` +- `pnpm format:check` + +## 四、代码改动的最低要求 + +所有合并前的改动至少要满足: + +- 能解释它服务于哪份文档 +- 有对应测试或可重复验证方式 +- 不破坏现有主流程 +- 没有遗留明显 TODO 却不写进 backlog +- 通过 `pnpm lint`、`pnpm architecture-check`、`pnpm test`、`pnpm typecheck`、`pnpm build` + +## 五、文档同步规则 + +以下情况必须同步更新文档: + +- 功能范围变化 +- 用户交互变化 +- 接口字段变化 +- 本地存储结构变化 +- OpenAPI 契约变化 +- SQLite migration 变化 +- 权限策略变化 +- 测试标准变化 +- 发布流程变化 + +## 六、评审时重点看什么 + +代码评审优先关注: + +- 是否偏离既定目标 +- 是否引入安全或权限风险 +- 是否破坏任务主流程 +- 是否缺少回归测试 +- 是否需要同步更新文档 +- 是否绕过了 `@mango/contracts` 或 workspace 公共入口 + +## 七、不鼓励的行为 + +- 只根据聊天记录开发,不更新正式文档 +- 看到“顺手可以一起做”的需求就临时扩 scope +- 为未来假设做过度设计 +- 在没有验证的情况下声称“已经可以了” +- 在 renderer 中直接调用 Electron / Node 能力 + +## 八、对新成员的建议 + +先通过文档理解 Mango 的产品边界和技术约束,再开始动代码。Mango 的长期价值来自“清晰、可信、可控”,这个原则比一时的功能堆砌更重要。 diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..35e962a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,298 @@ +# Mango 未实现功能计划清单 + +## 一、文档定位 + +本文件是 Mango 当前阶段的 **三级操作性文档**,用于汇总: + +- 现有正式文档里已经承诺、但尚未完成的功能 +- 从当前代码骨架可以明确看出的实现缺口 + +本文件 **不是** 新的 PRD,不替代 `docs/product/v1-backlog.md`、`docs/product/roadmap-and-milestones.md` 或技术架构文档。 +它的作用是把“以后要做什么”整理成一份可持续维护、可按阶段执行的清单。 + +## 二、编写依据 + +本清单主要基于以下内容整理,不单独发明需求: + +- `README.md` +- `docs/product/prd.md` +- `docs/product/v1-backlog.md` +- `docs/product/roadmap-and-milestones.md` +- `docs/engineering/detailed-architecture.md` + +### 使用规则 + +- 只有“文档已承诺但未落地”或“代码中已显著暴露的功能缺口”才进入本清单 +- 每项功能都应能映射到当前里程碑,不把中后期能力伪装成当前必做项 +- 这份清单用于排期、分解和追踪,不用于改写产品范围 + +## 三、当前阶段判断 + +结合现有文档和当前代码骨架,Mango 当前更接近: + +- `M1 可运行骨架` 已完成 +- `M2 真实执行闭环` 已部分开始,但尚未完成 +- `M3 Beta 可用性打磨` 和 `M4 公开测试前准备` 仍有较多缺口 + +当前已经具备: + +- 桌面工作台界面 +- `Plan -> Approve -> Go -> Review` 主流程骨架 +- 任务状态机、权限模型、事件模型 +- `Claude Code CLI` 与 mock adapter 共存 +- SQLite 主存储与 JSON 导入兜底 +- 基础测试、构建、类型检查和结构校验链路 + +因此,本计划重点不再是“从零搭壳”,而是把已有骨架补齐成更真实可用的桌面 Agent 工作台。 + +## 四、M2:真实执行闭环 + +### 4.1 执行闭环 + +#### [文档承诺][代码差距] 真实 CLI 执行能力补强 + +- 当前状态:代码里已存在 `ClaudeCodeCliAdapter`,但当前真实执行能力仍偏薄,历史文档中也仍有“尚未接入真实生产级 CLI Agent”的表述残留。 +- 缺口说明:当前真实 adapter 仅能稳定返回计划、一个终端输出事件和一个总结事件,还不足以支撑完整的真实执行闭环。 +- 目标结果:真实 CLI adapter 成为主执行路径,能在真实项目中完成一轮可验证的端到端任务。 +- 优先级:P0 +- 依赖/前置条件:现有 adapter 接口、主进程编排链路、工作区上下文已存在。 +- 完成标志:团队可以在真实本地项目中完成一次从输入、审批、执行到回顾的完整任务闭环,并能稳定复现。 +- 备注:这项工作的重点从“是否有真实 adapter”转为“真实 adapter 是否足够支撑产品主路径”。 + +#### [代码差距] 流式执行事件 + +- 当前状态:执行事件当前按数组一次性返回。 +- 缺口说明:长任务期间缺少持续反馈,用户只能在阶段结束后看到结果,不符合“执行过程可见”的产品原则。 +- 目标结果:执行期间持续产出事件,让 UI 能增量展示任务仍在运行、当前做了什么、是否出现问题。 +- 优先级:P0 +- 依赖/前置条件:真实 CLI 执行能力补强。 +- 完成标志:长任务执行时,时间线可持续刷新,不再依赖单次返回全量事件。 +- 备注:这是 Review 和失败定位能力的前置条件。 + +#### [文档承诺][代码差距] 文件变化与工具调用事件 + +- 当前状态:领域模型已定义 `file.change` 和 `tool.call`,但真实 CLI adapter 目前没有稳定产出这些事件。 +- 缺口说明:结果回顾缺少结构化证据,用户无法快速确认“改了哪些文件”“用了哪些工具”。 +- 目标结果:真实执行阶段能输出文件变化和关键工具调用信息,供时间线和 Review 面板复用。 +- 优先级:P0 +- 依赖/前置条件:流式执行事件或更丰富的执行事件采集机制。 +- 完成标志:至少能在任务回顾里看到变更文件列表,在时间线中看到关键工具调用或等价执行证据。 +- 备注:这是 PRD 中“结果摘要与变更文件列表”要求的直接落地点。 + +#### [文档承诺][代码差距] 取消控制 + +- 当前状态:文档把取消作为可理解的主流程一部分,但当前 UI 和主进程未形成完整取消控制面。 +- 缺口说明:任务一旦开始执行,用户缺少清晰的主动停止能力。 +- 目标结果:用户可在执行中取消任务,系统能安全结束执行并保留必要上下文。 +- 优先级:P0 +- 依赖/前置条件:真实 CLI 执行补强、任务状态管理。 +- 完成标志:执行中任务可被用户取消,任务状态进入 `cancelled`,关键事件和总结仍被保留。 +- 备注:取消能力与失败恢复共同决定真实可用性。 + +#### [文档承诺][代码差距] 失败重试 + +- 当前状态:失败会进入 `failed`,但尚未形成“基于上下文重试”的完整产品动作。 +- 缺口说明:失败后用户仍需要从头再来,不符合真实桌面任务的使用方式。 +- 目标结果:失败后支持合理重试,保留必要上下文,而不是一切归零。 +- 优先级:P0 +- 依赖/前置条件:失败上下文保留、任务历史、执行事件补强。 +- 完成标志:失败任务可从界面发起重试,并带着上次任务的必要上下文重新进入主流程。 +- 备注:需避免把“重复创建新任务”误当成重试能力。 + +#### [文档承诺][代码差距] 任务恢复 + +- 当前状态:持久化层已具备历史与 active task 存储能力,但尚未形成用户可感知的恢复体验。 +- 缺口说明:应用中断、重启或任务异常中止后,当前缺少稳定恢复路径。 +- 目标结果:用户在应用重新启动后,可以识别并继续处理未完成或失败的任务。 +- 优先级:P0 +- 依赖/前置条件:SQLite 主存储、任务状态机、失败重试能力。 +- 完成标志:重启应用后,未完成或失败任务能被重新载入,并提供继续处理入口。 +- 备注:这是从“演示级可跑”走向“真实可用”的关键能力。 + +### 4.2 工作区与权限 + +#### [文档承诺][代码差距] 最近工作区 + +- 当前状态:工作区模型存在,但没有完整的“最近工作区”产品能力。 +- 缺口说明:开发者跨项目切换频繁,当前工作区体验仍偏单一和静态。 +- 目标结果:支持最近工作区列表和快速回到常用项目。 +- 优先级:P0 +- 依赖/前置条件:工作区持久化与状态刷新能力。 +- 完成标志:用户能看到并选择最近工作区,且状态信息正确刷新。 +- 备注:属于 M2/M3 之间的过渡能力,建议尽早做。 + +#### [文档承诺][代码差距] 工作区切换与状态刷新 + +- 当前状态:界面能展示工作区,但切换和刷新能力仍偏基础。 +- 缺口说明:当前工作区上下文对真实任务影响极大,若切换和刷新体验不足,会直接影响任务可信度。 +- 目标结果:支持工作区切换、状态刷新、Git 分支与改动摘要同步更新。 +- 优先级:P0 +- 依赖/前置条件:工作区模型与主进程刷新逻辑已存在。 +- 完成标志:切换工作区后,主任务上下文、Git 状态和后续计划生成都能正确更新。 +- 备注:这是工作区相关能力的最低可用要求。 + +#### [文档承诺][代码差距] 权限从展示升级为真正控制点 + +- 当前状态:系统已能评估权限请求并展示风险,但执行控制仍偏任务级整体批准。 +- 缺口说明:当前权限更像“提示”,还不是用户真正信任系统的控制点。 +- 目标结果:权限批准成为执行前的真实闸门,用户能明确知道批了什么、没批什么。 +- 优先级:P0 +- 依赖/前置条件:更明确的权限请求表达、执行链路对权限决策的消费。 +- 完成标志:高风险能力的执行行为与用户批准结果严格一致,拒绝时系统能给出明确反馈。 +- 备注:这是 Mango 的信任核心,优先级必须持续保持最高。 + +#### [文档承诺][代码差距] 更细粒度的权限作用域与风险说明 + +- 当前状态:当前权限模型已区分 `shell`、`filesystem`、`network`、`browser`,但作用域和风险说明仍偏粗。 +- 缺口说明:用户仍难以判断“允许之后具体会发生什么”。 +- 目标结果:权限解释更具体,作用域更明确,风险说明更接近日常开发者理解方式。 +- 优先级:P0 +- 依赖/前置条件:权限控制点强化。 +- 完成标志:用户可看见每类高风险权限的用途、风险级别和大致影响范围,并能基于此做决定。 +- 备注:这项能力不一定要求一次做到极细,但必须明显优于当前状态。 + +### 4.3 回顾与历史 + +#### [文档承诺][代码差距] 更完整的 Review 面板 + +- 当前状态:已有基本回顾摘要,但内容仍偏少。 +- 缺口说明:任务结束后,用户还不能足够快地判断结果是否可接受。 +- 目标结果:Review 面板能集中展示结果摘要、关键证据、失败原因和下一步建议。 +- 优先级:P0 +- 依赖/前置条件:执行事件补强、文件变化与工具调用事件。 +- 完成标志:任务结束后,用户无需翻时间线全量日志,也能快速完成结果判断。 +- 备注:这是“结果必须能回顾”原则的直接产品体现。 + +#### [文档承诺][代码差距] 文件变化说明 + +- 当前状态:文档要求展示变更文件列表,但当前真实执行路径仍未稳定提供。 +- 缺口说明:即使执行成功,用户也不一定知道改动范围和重点。 +- 目标结果:回顾区展示变更文件列表,并对关键变化给出结构化说明或等价证据。 +- 优先级:P0 +- 依赖/前置条件:文件变化事件落地。 +- 完成标志:至少能稳定展示变更文件清单,并能区分“没有变化”和“变化尚未采集”。 +- 备注:这项能力不应只依赖原始终端输出。 + +#### [文档承诺][代码差距] 结构化结果结论区 + +- 当前状态:已有 `summary.ready` 事件和基础摘要,但结论区不够强。 +- 缺口说明:用户仍要自己判断“这次任务是否完成、失败点在哪里、接下来要不要继续”。 +- 目标结果:任务结束后形成更明确的结论区,帮助用户快速做下一步决策。 +- 优先级:P0 +- 依赖/前置条件:Review 面板增强。 +- 完成标志:成功、失败、部分完成三类结果都能给出结构化结论与下一步建议。 +- 备注:结论区应优先服务判断,而不是重复原始日志。 + +#### [文档承诺][代码差距] 历史任务与检索能力 + +- 当前状态:历史任务已进入持久化,但读取与回看能力仍偏基础。 +- 缺口说明:随着任务积累,缺少历史查看和定位能力会削弱复盘价值。 +- 目标结果:任务历史可按基本维度查看,并能回看关键结果。 +- 优先级:P1 +- 依赖/前置条件:SQLite 主存储、Review 面板增强。 +- 完成标志:用户能在历史任务中查看过往结果和关键信息,不必依赖当前 active task。 +- 备注:检索能力可以先做基础版,不需要一步做到全文搜索。 + +## 五、M3:Beta 可用性打磨 + +### 5.1 桌面产品化 + +#### [文档承诺][代码差距] 首次引导 + +- 当前状态:已有基础界面,但新用户教育路径不完整。 +- 缺口说明:首次用户仍可能不理解工作区、权限和执行流程。 +- 目标结果:提供清晰的首次引导,帮助用户理解产品主路径。 +- 优先级:P1 +- 依赖/前置条件:M2 主执行闭环稳定。 +- 完成标志:新用户首次打开后,能在较短时间内理解如何发起、审批和查看结果。 +- 备注:引导重点应放在主流程和风险理解,不要做成营销式 onboarding。 + +#### [文档承诺][代码差距] 反馈入口完善 + +- 当前状态:已有反馈入口雏形,但问题采集闭环仍不完整。 +- 缺口说明:试用用户反馈路径不够清晰,会影响 Beta 阶段问题收集效率。 +- 目标结果:用户能方便地提交反馈、问题和失败案例。 +- 优先级:P1 +- 依赖/前置条件:基础反馈入口已存在。 +- 完成标志:应用内反馈路径清晰可用,且能支撑 Beta 阶段问题采集。 +- 备注:重点是“清晰可达”,不要求一开始就做复杂反馈系统。 + +#### [文档承诺][代码差距] 安装包与更新闭环 + +- 当前状态:仓库具备打包脚本和发布文档骨架,但桌面产品化闭环仍未验证完整。 +- 缺口说明:如果安装、更新和版本通道不稳定,产品无法进入可信试用阶段。 +- 目标结果:形成较完整的安装、更新、版本通道与发布验证闭环。 +- 优先级:P1 +- 依赖/前置条件:M2 主闭环稳定、跨平台构建链路可用。 +- 完成标志:团队可以构建、安装、更新并验证 Beta 版本,不只是单次本地演示。 +- 备注:这项能力属于从研发骨架向试用产品转化的必要条件。 + +#### [文档承诺][代码差距] 跨平台验证补齐 + +- 当前状态:目标平台是桌面跨平台,但当前实现与验证重心仍主要集中在桌面骨架期。 +- 缺口说明:缺少系统性的跨平台验证会放大路径、shell、文件系统和安装差异风险。 +- 目标结果:针对 Windows、macOS、Linux 补齐基本验证。 +- 优先级:P1 +- 依赖/前置条件:安装包与更新闭环基础可用。 +- 完成标志:关键主流程至少在目标平台上完成 smoke 或更高层级验证。 +- 备注:应优先覆盖主路径和高风险差异,不必一开始求全。 + +## 六、M4:公开测试前准备 + +### 6.1 中后期能力与公开分发准备 + +#### [文档承诺][代码差距] 多 adapter 支持 + +- 当前状态:代码中已有多个 adapter 注册能力,但产品层面的选择、管理和稳定切换体验仍较初级。 +- 缺口说明:当前“有多个 adapter 类”不等于“具备成熟的多 adapter 产品能力”。 +- 目标结果:在单一主路径稳定后,再提供更完整的多 adapter 支持。 +- 优先级:P2 +- 依赖/前置条件:真实 CLI 执行主路径稳定、权限和回顾能力成熟。 +- 完成标志:用户能清晰地理解、选择并切换可用 adapter,不破坏主流程体验。 +- 备注:在单 adapter 没跑稳前,不应提前提升优先级。 + +#### [文档承诺] 更复杂浏览器自动化 + +- 当前状态:文档明确列为可延后能力。 +- 缺口说明:当前主路径仍聚焦开发者本地任务执行,不应被重型浏览器自动化分散。 +- 目标结果:仅在桌面主流程成熟后,再评估是否纳入后续路线。 +- 优先级:P2 +- 依赖/前置条件:M2、M3 主问题解决。 +- 完成标志:不作为当前阶段验收项,仅在未来路线图中重新评估。 +- 备注:保留在清单中是为了防止被临时插队,而不是推动其尽快实现。 + +#### [文档承诺][代码差距] 未来 Web/API/Worker 预留能力 + +- 当前状态:仓库里已有 `apps/web`、`apps/api`、`apps/worker` 骨架占位,但没有正式产品级实现。 +- 缺口说明:这些入口已存在,但当前阶段不应与桌面主闭环竞争资源。 +- 目标结果:在桌面本地执行主链路成熟后,再按模块化方式补正式实现。 +- 优先级:P2 +- 依赖/前置条件:桌面产品化和公开测试能力先稳定。 +- 完成标志:不作为当前阶段功能交付要求,只作为后续架构演进预留项。 +- 备注:应避免把“目录存在”误判成“能力已进入开发优先级”。 + +## 七、不纳入当前计划 + +以下内容在当前阶段 **明确不纳入本计划的执行范围**,仅保留为边界说明: + +### 7.1 已在正式文档中明确排除的能力 + +- 团队协作与组织账号 +- 订阅、支付、许可证 +- 云端同步 +- 重型浏览器自动化平台 +- 自研模型推理引擎 +- 大而全的办公自动化能力 + +### 7.2 当前不单列推进的原因 + +- 不属于 Mango v1 的核心验证问题 +- 会明显打断当前里程碑节奏 +- 当前桌面本地主链路尚未稳定,不适合继续扩散范围 + +## 八、维护建议 + +- 每完成一个阶段里程碑,就回看一次本文件 +- 当某项能力已完成,应及时从“未实现清单”中移除或标为已完成 +- 若正式文档改变了产品范围,应先更新一级或二级文档,再回头调整本清单 +- 若代码与本文件冲突,以最新确认的正式文档和真实代码行为为准,并及时修正文档 diff --git a/README.md b/README.md index e78b5a3..0a9b969 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,112 @@ # Mango -You Plan, Mango Goes. + +`You Plan, Mango Goes.` + +Mango 是一个面向开发者的桌面端 Agent 工作台。它不想成为“另一个聊天框”,而是要成为一个让开发者放心把真实任务交出去执行的桌面执行中枢。 + +Mango 的核心体验是四步闭环: + +1. `Plan`:先生成计划,不盲目开跑 +2. `Approve`:高风险能力必须显式授权 +3. `Go`:执行过程可见,日志、文件变化、事件统一展示 +4. `Review`:任务结束后留下可回顾、可复盘的结果 + +## 项目定位 + +- 用户群体:独立开发者、全栈工程师、重度本地开发工具用户 +- 产品形态:跨平台桌面应用,优先服务本地工程工作流 +- 产品理念:`Man, Go!`,用户决定方向,Mango 负责执行 +- 价值主张:让 Agent 执行变得可控、可信、可追踪、可复盘 + +## 当前仓库包含什么 + +- `apps/desktop`:Electron + React + TypeScript 桌面应用 +- `apps/web`、`apps/api`、`apps/worker`:未来 Web / 云端骨架占位 +- `packages/core`:任务生命周期、权限策略、共享领域模型 +- `packages/adapters`:Agent 适配器层,当前提供 `MockClaudeCodeAdapter` +- `packages/contracts`:共享 DTO、IPC channel、错误模型与未来 API 契约 +- `packages/ui`:共享 design token 与 UI 基础资产 +- `packages/config-*`:TS、ESLint、Vitest、Playwright 共享配置 +- `tooling/`:工程脚本、目录检查、模板与生成器 +- `docs/`:完整的中文产品、设计、研发、测试、发布、流程文档体系 +- `.github/`:CI 工作流和 Issue 模板 + +## 当前实现状态 + +当前仓库已经完成第一批基础骨架,适合作为后续正式研发的起点: + +- 已有桌面工作台界面 +- 已有 `Plan -> Approve -> Go -> Review` 主流程骨架 +- 已有任务状态机、权限模型、事件模型 +- 已有本地持久化的最小实现 +- 已有用于产品演进的 mock adapter +- 已有 `pnpm + turbo` Monorepo 底座 +- 已有共享 contracts / config 包 +- 已有测试、类型检查、构建与结构校验链路 + +当前仍然是“产品骨架阶段”,尚未接入真实生产级 CLI Agent。 + +## 快速启动 + +```bash +pnpm install +pnpm test +pnpm typecheck +pnpm build +pnpm dev +``` + +## 常用脚本 + +- `pnpm dev`:启动桌面应用开发环境 +- `pnpm lint`:执行 ESLint Flat Config 校验 +- `pnpm format:check`:执行格式检查 +- `pnpm architecture-check`:执行目录与依赖边界检查 +- `pnpm test`:运行全部单元测试与界面测试 +- `pnpm typecheck`:执行 TypeScript 类型检查 +- `pnpm build`:构建 workspace 包与桌面应用 +- `pnpm smoke:desktop`:验证桌面构建产物 +- `pnpm package`:生成桌面安装包 +- `pnpm hooks:install`:安装本地 Git hooks +- `pnpm prtitle:check -- ""`:校验 PR 标题格式 +- `pnpm verify`:执行默认发布前校验链路 + +## 开发过程中最重要的文档入口 + +如果你第一次参与 Mango,建议按下面顺序阅读: + +1. [docs/README.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/README.md) +2. [docs/product/vision-and-positioning.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/product/vision-and-positioning.md) +3. [docs/product/prd.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/product/prd.md) +4. [docs/product/roadmap-and-milestones.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/product/roadmap-and-milestones.md) +5. [docs/engineering/technical-architecture.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/technical-architecture.md) +6. [docs/engineering/module-contracts.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/module-contracts.md) +7. [docs/engineering/engineering-standards-overview.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/engineering-standards-overview.md) +8. [docs/engineering/code-style-and-naming-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/code-style-and-naming-standards.md) +9. [docs/engineering/api-and-database-naming-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/api-and-database-naming-standards.md) +10. [docs/engineering/openapi-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/openapi-standards.md) +11. [docs/engineering/sqlite-migration-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/sqlite-migration-standards.md) +12. [docs/engineering/monorepo-and-directory-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/engineering/monorepo-and-directory-standards.md) +13. [docs/process/pull-request-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/process/pull-request-standards.md) +14. [docs/launch/versioning-and-release-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/launch/versioning-and-release-standards.md) +15. [docs/quality/quality-gates-and-testing-standards.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/quality/quality-gates-and-testing-standards.md) +16. [docs/launch/checklist.md](D:/willxue/FruitsAI/Mango/.worktrees/codex-mango-v1/docs/launch/checklist.md) + +## 文档原则 + +Mango 的文档不是“补充材料”,而是研发过程的一部分: + +- 没有文档定义的范围,不默认进入开发 +- 影响公共行为的改动,必须同步更新相应文档 +- 如果代码与文档冲突,以“最新确认的目标文档”作为判断依据 +- 新成员应优先通过文档建立共识,再进入编码 + +## 下一阶段重点 + +第一阶段文档与骨架完成后,下一步建议优先推进: + +1. 接入真实的 `Claude Code CLI` 或等价生产级 Adapter +2. 将本地持久化从 JSON 升级到 SQLite +3. 增加工作区管理、任务恢复、失败重试、取消控制 +4. 为未来 `apps/api` 与 `apps/worker` 补正式实现 +5. 完成 Beta 上线所需的安装包验证和反馈闭环 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..44ba940 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# 安全说明 + +Mango 是一个会触达本地文件系统、终端、网络与浏览器能力的桌面 Agent 产品,因此安全设计不是附属问题,而是产品本体的一部分。 + +## 一、当前安全原则 + +- 本地优先:默认把任务状态与设置保存在本地 +- 显式授权:高风险能力必须经过用户可见批准 +- 最小暴露:不默认发送提示词内容、文件内容、环境变量 +- 可回顾:任务执行后必须留下事件与结果摘要 + +## 二、当前高风险能力 + +以下能力在 Mango 中被视为需要重点控制: + +- Shell 执行 +- 文件创建、修改、删除 +- 网络访问 +- 浏览器自动化 + +## 三、漏洞反馈方式 + +如果你发现 Mango 存在安全问题,请不要直接公开披露利用细节。建议通过私下渠道先联系维护者,并提供: + +- 问题描述 +- 影响范围 +- 复现步骤 +- 可能的攻击面 +- 修复建议(如果有) + +## 四、后续必须补齐的安全能力 + +- 更细粒度的权限范围控制 +- 会话级与工作区级授权策略 +- 敏感目录保护策略 +- 审计事件持久化 +- 更明确的错误与异常上报分级 diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..4c5801a --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,6 @@ +# `apps/api` + +此目录预留给未来云后端 API 服务。 + +- 采用模块优先、模块内分层结构 +- 对外契约以 REST + OpenAPI 为准 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000..ad7c87c --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,19 @@ +appId: ai.fruits.mango +productName: Mango +directories: + output: release +files: + - out/** +publish: + provider: github + owner: FruitsAI + repo: Mango +win: + target: + - nsis +mac: + target: + - dmg +linux: + target: + - AppImage diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 0000000..9351a8e --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path' + +import react from '@vitejs/plugin-react' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' + +export default defineConfig({ + main: { + resolve: { + alias: { + '@mango/core': resolve(__dirname, '../../packages/core/src/index.ts'), + '@mango/adapters': resolve(__dirname, '../../packages/adapters/src/index.ts'), + '@mango/contracts': resolve(__dirname, '../../packages/contracts/src/index.ts'), + '@mango/ui': resolve(__dirname, '../../packages/ui/src/index.ts') + } + }, + plugins: [externalizeDepsPlugin()] + }, + preload: { + resolve: { + alias: { + '@mango/core': resolve(__dirname, '../../packages/core/src/index.ts'), + '@mango/adapters': resolve(__dirname, '../../packages/adapters/src/index.ts'), + '@mango/contracts': resolve(__dirname, '../../packages/contracts/src/index.ts'), + '@mango/ui': resolve(__dirname, '../../packages/ui/src/index.ts') + } + }, + plugins: [externalizeDepsPlugin()] + }, + renderer: { + resolve: { + alias: { + '@mango/core': resolve(__dirname, '../../packages/core/src/index.ts'), + '@mango/adapters': resolve(__dirname, '../../packages/adapters/src/index.ts'), + '@mango/contracts': resolve(__dirname, '../../packages/contracts/src/index.ts'), + '@mango/ui': resolve(__dirname, '../../packages/ui/src/index.ts') + } + }, + plugins: [react()] + } +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..8a34b4a --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,32 @@ +{ + "name": "@mango/desktop", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build:app": "electron-vite build", + "build": "pnpm run build:app", + "package": "electron-builder --config electron-builder.yml", + "test": "pnpm run test:main && pnpm run test:renderer", + "test:main": "vitest run --config vitest.main.config.ts", + "test:renderer": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13" + }, + "dependencies": { + "@fontsource/ibm-plex-mono": "^5.1.1", + "@fontsource/ibm-plex-sans": "^5.1.1", + "@fontsource/syne": "^5.1.1", + "@mango/adapters": "workspace:*", + "@mango/contracts": "workspace:*", + "@mango/core": "workspace:*", + "@mango/ui": "workspace:*", + "better-sqlite3": "^12.8.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/apps/desktop/src/main/app/bootstrapDesktopApp.ts b/apps/desktop/src/main/app/bootstrapDesktopApp.ts new file mode 100644 index 0000000..eab864b --- /dev/null +++ b/apps/desktop/src/main/app/bootstrapDesktopApp.ts @@ -0,0 +1,74 @@ +import { join } from 'node:path' + +import { app, BrowserWindow, shell } from 'electron' + +import type { GeneratePlanInput } from '@mango/contracts' + +import { registerDesktopIpcHandlers } from '../ipc/registerDesktopIpcHandlers' +import { FileDesktopStore } from '../persistence/fileDesktopStore' +import { SQLiteDesktopStore } from '../persistence/sqliteDesktopStore' +import { DesktopController } from '../services/desktopController' + +declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined +declare const MAIN_WINDOW_VITE_NAME: string + +let mainWindow: BrowserWindow | null = null + +const createMainWindow = () => { + mainWindow = new BrowserWindow({ + width: 1540, + height: 980, + minWidth: 1180, + minHeight: 760, + backgroundColor: '#140f0a', + titleBarStyle: 'hiddenInset', + webPreferences: { + preload: join(__dirname, '../../preload/index.mjs'), + contextIsolation: true, + nodeIntegration: false + } + }) + + if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { + void mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL) + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } else { + void mainWindow.loadFile(join(__dirname, `../../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)) + } +} + +export const bootstrapDesktopApp = () => { + const legacyStore = new FileDesktopStore(join(app.getPath('userData'), 'desktop-state.json')) + const store = new SQLiteDesktopStore( + join(app.getPath('userData'), 'desktop-state.db'), + legacyStore + ) + const controller = new DesktopController(store) + + app.whenReady().then(() => { + registerDesktopIpcHandlers({ + bootstrap: () => controller.bootstrap(), + generatePlan: (input: GeneratePlanInput) => controller.generatePlan(input), + approveAndRun: () => controller.approveAndRun(), + openFeedback: async () => { + await shell.openExternal('https://github.com/FruitsAI/Mango/issues/new/choose') + } + }) + + createMainWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow() + } + }) + }) + + app.on('window-all-closed', () => { + store.close() + + if (process.platform !== 'darwin') { + app.quit() + } + }) +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts new file mode 100644 index 0000000..3672174 --- /dev/null +++ b/apps/desktop/src/main/index.ts @@ -0,0 +1,3 @@ +import { bootstrapDesktopApp } from './app/bootstrapDesktopApp' + +bootstrapDesktopApp() diff --git a/apps/desktop/src/main/ipc/registerDesktopIpcHandlers.ts b/apps/desktop/src/main/ipc/registerDesktopIpcHandlers.ts new file mode 100644 index 0000000..05cab67 --- /dev/null +++ b/apps/desktop/src/main/ipc/registerDesktopIpcHandlers.ts @@ -0,0 +1,19 @@ +import { ipcMain } from 'electron' + +import { MANGO_DESKTOP_CHANNELS, type GeneratePlanInput } from '@mango/contracts' + +interface DesktopIpcHandlers { + bootstrap: () => Promise<unknown> + generatePlan: (input: GeneratePlanInput) => Promise<unknown> + approveAndRun: () => Promise<unknown> + openFeedback: () => Promise<void> +} + +export const registerDesktopIpcHandlers = (handlers: DesktopIpcHandlers) => { + ipcMain.handle(MANGO_DESKTOP_CHANNELS.bootstrap, () => handlers.bootstrap()) + ipcMain.handle(MANGO_DESKTOP_CHANNELS.generatePlan, (_event, input: GeneratePlanInput) => + handlers.generatePlan(input) + ) + ipcMain.handle(MANGO_DESKTOP_CHANNELS.approveAndRun, () => handlers.approveAndRun()) + ipcMain.handle(MANGO_DESKTOP_CHANNELS.openFeedback, () => handlers.openFeedback()) +} diff --git a/apps/desktop/src/main/persistence/fileDesktopStore.test.ts b/apps/desktop/src/main/persistence/fileDesktopStore.test.ts new file mode 100644 index 0000000..1d5ef84 --- /dev/null +++ b/apps/desktop/src/main/persistence/fileDesktopStore.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment node + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + currentDesktopSchemaVersion, + defaultDesktopSettings, + FileDesktopStore +} from './fileDesktopStore' + +describe('FileDesktopStore', () => { + it('writes the current schemaVersion into persisted desktop state', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'mango-file-store-')) + const filePath = join(tempDirectory, 'desktop-state.json') + const workspace = { + id: 'workspace-main', + name: 'Main workspace', + rootPath: tempDirectory, + shell: 'powershell', + gitBranch: 'main', + gitStatusSummary: 'clean', + envAllowList: ['PATH'], + recentTaskIds: [], + providerConfig: { + configuredAdapters: ['mock-claude'] + } + } + const session = { + id: 'task-001', + adapterId: 'mock-claude', + prompt: 'Plan Mango persistence work', + status: 'planned' as const, + workspace, + plan: { + headline: 'Plan Mango persistence work', + summary: 'Upgrade local desktop state and add migration scaffolding.', + steps: ['Add schemaVersion', 'Add migration catalog'], + requestedPermissions: [] + }, + events: [], + createdAt: '2026-03-24T00:00:00.000Z', + updatedAt: '2026-03-24T00:00:00.000Z' + } + const store = new FileDesktopStore(filePath) + + try { + await store.write({ + schemaVersion: currentDesktopSchemaVersion, + activeTask: session, + history: [session], + settings: defaultDesktopSettings, + selectedWorkspaceId: workspace.id, + workspaces: [workspace] + }) + + const content = await readFile(filePath, 'utf8') + + expect(content).toContain(`"schemaVersion": ${currentDesktopSchemaVersion}`) + } finally { + await rm(tempDirectory, { recursive: true, force: true }) + } + }, 10000) + + it('upgrades legacy persisted state that has no schemaVersion field yet', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'mango-file-store-')) + const filePath = join(tempDirectory, 'desktop-state.json') + const workspace = { + id: 'workspace-main', + name: 'Main workspace', + rootPath: tempDirectory, + shell: 'powershell', + gitBranch: 'main', + gitStatusSummary: 'clean', + envAllowList: ['PATH'], + recentTaskIds: [], + providerConfig: { + configuredAdapters: ['mock-claude'] + } + } + const session = { + id: 'task-legacy', + adapterId: 'mock-claude', + prompt: 'Migrate legacy Mango state', + status: 'draft' as const, + workspace, + events: [], + createdAt: '2026-03-24T00:00:00.000Z', + updatedAt: '2026-03-24T00:00:00.000Z' + } + const store = new FileDesktopStore(filePath) + + try { + await writeFile( + filePath, + JSON.stringify( + { + activeTask: session, + history: [session], + settings: defaultDesktopSettings, + selectedWorkspaceId: workspace.id, + workspaces: [workspace] + }, + null, + 2 + ), + 'utf8' + ) + + const state = await store.read() + + expect(state).not.toBeNull() + expect(state?.schemaVersion).toBe(currentDesktopSchemaVersion) + expect(state?.history).toHaveLength(1) + } finally { + await rm(tempDirectory, { recursive: true, force: true }) + } + }, 10000) +}) diff --git a/apps/desktop/src/main/persistence/fileDesktopStore.ts b/apps/desktop/src/main/persistence/fileDesktopStore.ts new file mode 100644 index 0000000..bdbcd5e --- /dev/null +++ b/apps/desktop/src/main/persistence/fileDesktopStore.ts @@ -0,0 +1,98 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { DesktopSettings } from '@mango/contracts' +import type { TaskSession, WorkspaceContext } from '@mango/core' + +export interface PersistedDesktopState { + schemaVersion: number + activeTask: TaskSession | null + history: TaskSession[] + settings: DesktopSettings + selectedWorkspaceId: string + workspaces: WorkspaceContext[] +} + +export interface DesktopStateStore { + read: () => Promise<PersistedDesktopState | null> + write: (state: PersistedDesktopState) => Promise<void> +} + +type LegacyPersistedDesktopState = Omit<PersistedDesktopState, 'schemaVersion'> & { + schemaVersion?: number +} + +export const currentDesktopSchemaVersion = 1 + +export const defaultDesktopSettings: DesktopSettings = { + telemetryEnabled: true, + releaseChannel: 'beta' +} + +const isObjectRecord = (value: unknown): value is Record<string, unknown> => + typeof value === 'object' && value !== null + +const normalizePersistedDesktopState = (value: unknown): PersistedDesktopState | null => { + if (!isObjectRecord(value)) { + return null + } + + const candidate = value as LegacyPersistedDesktopState + + if (!Array.isArray(candidate.history) || !Array.isArray(candidate.workspaces)) { + return null + } + + if (!candidate.settings || typeof candidate.selectedWorkspaceId !== 'string') { + return null + } + + const nextSchemaVersion = + candidate.schemaVersion === undefined + ? currentDesktopSchemaVersion + : candidate.schemaVersion === currentDesktopSchemaVersion + ? candidate.schemaVersion + : null + + if (nextSchemaVersion === null) { + return null + } + + return { + schemaVersion: nextSchemaVersion, + activeTask: candidate.activeTask ?? null, + history: candidate.history, + settings: candidate.settings, + selectedWorkspaceId: candidate.selectedWorkspaceId, + workspaces: candidate.workspaces + } +} + +export class FileDesktopStore implements DesktopStateStore { + public constructor(private readonly filePath: string) {} + + public async read(): Promise<PersistedDesktopState | null> { + try { + const raw = await readFile(this.filePath, 'utf8') + return normalizePersistedDesktopState(JSON.parse(raw)) + } catch { + return null + } + } + + public async write(state: PersistedDesktopState): Promise<void> { + await mkdir(dirname(this.filePath), { recursive: true }) + await writeFile( + this.filePath, + JSON.stringify( + { + ...state, + schemaVersion: currentDesktopSchemaVersion + }, + null, + 2 + ), + 'utf8' + ) + } +} diff --git a/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.rollback.sql b/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.rollback.sql new file mode 100644 index 0000000..b432a03 --- /dev/null +++ b/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.rollback.sql @@ -0,0 +1,12 @@ +PRAGMA foreign_keys = ON; + +BEGIN IMMEDIATE; + +DROP INDEX IF EXISTS idx_execution_events__task_session_id_created_at; +DROP INDEX IF EXISTS idx_task_sessions__workspace_id_updated_at; +DROP TABLE IF EXISTS desktop_settings; +DROP TABLE IF EXISTS execution_events; +DROP TABLE IF EXISTS task_sessions; +DROP TABLE IF EXISTS workspaces; + +COMMIT; diff --git a/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.sql b/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.sql new file mode 100644 index 0000000..b9164a8 --- /dev/null +++ b/apps/desktop/src/main/persistence/migrations/0001_create_desktop_runtime_tables.sql @@ -0,0 +1,63 @@ +PRAGMA foreign_keys = ON; + +BEGIN IMMEDIATE; + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + root_path TEXT NOT NULL, + shell TEXT NOT NULL, + git_branch TEXT NOT NULL, + git_status_summary TEXT NOT NULL, + env_allow_list_json TEXT NOT NULL, + recent_task_ids_json TEXT NOT NULL, + provider_config_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS task_sessions ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + adapter_id TEXT NOT NULL, + prompt TEXT NOT NULL, + status TEXT NOT NULL, + approved_by TEXT, + plan_json TEXT, + execution_summary TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces (id) +); + +CREATE TABLE IF NOT EXISTS execution_events ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + type TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + payload_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (task_session_id) REFERENCES task_sessions (id) +); + +CREATE TABLE IF NOT EXISTS desktop_settings ( + id TEXT PRIMARY KEY, + schema_version INTEGER NOT NULL, + selected_workspace_id TEXT NOT NULL, + telemetry_enabled INTEGER NOT NULL, + release_channel TEXT NOT NULL, + active_task_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (selected_workspace_id) REFERENCES workspaces (id), + FOREIGN KEY (active_task_id) REFERENCES task_sessions (id) +); + +CREATE INDEX IF NOT EXISTS idx_task_sessions__workspace_id_updated_at + ON task_sessions (workspace_id, updated_at); + +CREATE INDEX IF NOT EXISTS idx_execution_events__task_session_id_created_at + ON execution_events (task_session_id, created_at); + +COMMIT; diff --git a/apps/desktop/src/main/persistence/sqliteDesktopStore.test.ts b/apps/desktop/src/main/persistence/sqliteDesktopStore.test.ts new file mode 100644 index 0000000..b79450b --- /dev/null +++ b/apps/desktop/src/main/persistence/sqliteDesktopStore.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + FileDesktopStore, + currentDesktopSchemaVersion, + defaultDesktopSettings +} from './fileDesktopStore' +import { SQLiteDesktopStore } from './sqliteDesktopStore' + +const createSamplePersistedState = (rootPath: string) => { + const workspace = { + id: 'workspace-main', + name: 'Main workspace', + rootPath, + shell: 'powershell', + gitBranch: 'main', + gitStatusSummary: 'clean', + envAllowList: ['PATH'], + recentTaskIds: ['task-001'], + providerConfig: { + configuredAdapters: ['mock-claude'] + } + } + + const activeTask = { + id: 'task-001', + adapterId: 'mock-claude', + prompt: 'Move Mango persistence to SQLite', + status: 'succeeded' as const, + workspace, + plan: { + headline: 'Persist Mango state in SQLite', + summary: 'Create a SQLite store and import legacy JSON state.', + steps: ['Create SQLite store', 'Import legacy JSON'], + requestedPermissions: [] + }, + events: [ + { + id: 'event-001', + type: 'terminal.output' as const, + level: 'info' as const, + message: 'Running migration catalog', + createdAt: '2026-03-24T00:00:00.000Z' + }, + { + id: 'event-002', + type: 'summary.ready' as const, + level: 'info' as const, + message: 'SQLite import finished', + summary: 'Migration completed successfully.', + createdAt: '2026-03-24T00:00:01.000Z' + } + ], + executionSummary: 'Migration completed successfully.', + approvedBy: 'desktop-user', + createdAt: '2026-03-24T00:00:00.000Z', + updatedAt: '2026-03-24T00:00:02.000Z' + } + + return { + schemaVersion: currentDesktopSchemaVersion, + activeTask, + history: [activeTask], + settings: defaultDesktopSettings, + selectedWorkspaceId: workspace.id, + workspaces: [workspace] + } +} + +describe('SQLiteDesktopStore', () => { + it('loads better-sqlite3 as the desktop persistence runtime', async () => { + await expect(import('better-sqlite3')).resolves.toMatchObject({ + default: expect.any(Function) + }) + }) + + it('writes and reads desktop state through SQLite', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'mango-sqlite-store-')) + const databasePath = join(tempDirectory, 'desktop-state.db') + const state = createSamplePersistedState(tempDirectory) + const store = new SQLiteDesktopStore(databasePath) + + try { + await store.write(state) + + const restored = await store.read() + + expect(restored).not.toBeNull() + expect(restored?.schemaVersion).toBe(currentDesktopSchemaVersion) + expect(restored?.selectedWorkspaceId).toBe(state.selectedWorkspaceId) + expect(restored?.history).toHaveLength(1) + expect(restored?.activeTask?.events).toHaveLength(2) + expect(restored?.activeTask?.executionSummary).toBe('Migration completed successfully.') + expect(restored?.workspaces[0]?.providerConfig?.configuredAdapters).toEqual(['mock-claude']) + } finally { + store.close() + await rm(tempDirectory, { recursive: true, force: true }) + } + }) + + it('imports legacy JSON state into SQLite on first read when database is empty', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'mango-sqlite-store-')) + const databasePath = join(tempDirectory, 'desktop-state.db') + const legacyFilePath = join(tempDirectory, 'desktop-state.json') + const state = createSamplePersistedState(tempDirectory) + const legacyStore = new FileDesktopStore(legacyFilePath) + const store = new SQLiteDesktopStore(databasePath, legacyStore) + + try { + await legacyStore.write(state) + + const imported = await store.read() + + expect(imported).not.toBeNull() + expect(imported?.history).toHaveLength(1) + expect(imported?.activeTask?.id).toBe('task-001') + + await rm(legacyFilePath, { force: true }) + + const restored = await store.read() + + expect(restored).not.toBeNull() + expect(restored?.activeTask?.id).toBe('task-001') + expect(restored?.settings.releaseChannel).toBe('beta') + } finally { + store.close() + await rm(tempDirectory, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/src/main/persistence/sqliteDesktopStore.ts b/apps/desktop/src/main/persistence/sqliteDesktopStore.ts new file mode 100644 index 0000000..e1c7d4c --- /dev/null +++ b/apps/desktop/src/main/persistence/sqliteDesktopStore.ts @@ -0,0 +1,520 @@ +import { readFileSync } from 'node:fs' + +import type { DesktopSettings } from '@mango/contracts' +import type { ExecutionEvent, TaskSession, WorkspaceContext } from '@mango/core' +import Database from 'better-sqlite3' + +import { + currentDesktopSchemaVersion, + defaultDesktopSettings, + type FileDesktopStore, + type PersistedDesktopState +} from './fileDesktopStore' +import { desktopSqliteMigrations } from './sqliteMigrationCatalog' + +interface StatementSyncLike { + all: (...parameters: unknown[]) => Record<string, unknown>[] + get: (...parameters: unknown[]) => Record<string, unknown> | undefined + run: (...parameters: unknown[]) => unknown +} + +interface DatabaseSyncInstance { + close: () => void + exec: (sql: string) => void + prepare: (sql: string) => StatementSyncLike +} + +interface WorkspaceRow { + id: string + name: string + rootPath: string + shell: string + gitBranch: string + gitStatusSummary: string + envAllowListJson: string + recentTaskIdsJson: string + providerConfigJson: string +} + +interface TaskSessionRow { + id: string + workspaceId: string + adapterId: string + prompt: string + status: TaskSession['status'] + approvedBy: string | null + planJson: string | null + executionSummary: string | null + createdAt: string + updatedAt: string +} + +interface ExecutionEventRow { + id: string + taskSessionId: string + type: ExecutionEvent['type'] + level: ExecutionEvent['level'] + message: string + payloadJson: string | null + createdAt: string +} + +interface DesktopSettingsRow { + id: string + schemaVersion: number + selectedWorkspaceId: string + telemetryEnabled: number + releaseChannel: DesktopSettings['releaseChannel'] + activeTaskId: string | null +} + +const desktopSettingsRecordId = 'desktop-state' +const now = (): string => new Date().toISOString() + +const parseJson = <TValue>(value: string | null, fallback: TValue): TValue => { + if (value === null) { + return fallback + } + + try { + return JSON.parse(value) as TValue + } catch { + return fallback + } +} + +const serializeExecutionEventPayload = (event: ExecutionEvent): string | null => { + switch (event.type) { + case 'terminal.output': + return null + case 'file.change': + return JSON.stringify({ filePath: event.filePath }) + case 'summary.ready': + return JSON.stringify({ summary: event.summary }) + case 'tool.call': + return JSON.stringify({ toolName: event.toolName }) + } +} + +const deserializeExecutionEvent = (row: ExecutionEventRow): ExecutionEvent => { + const payload = parseJson<Record<string, string>>(row.payloadJson, {}) + + switch (row.type) { + case 'terminal.output': + return { + id: row.id, + type: 'terminal.output', + level: row.level, + message: row.message, + createdAt: row.createdAt + } + case 'file.change': + return { + id: row.id, + type: 'file.change', + level: row.level, + message: row.message, + createdAt: row.createdAt, + filePath: payload.filePath ?? '' + } + case 'summary.ready': + return { + id: row.id, + type: 'summary.ready', + level: row.level, + message: row.message, + createdAt: row.createdAt, + summary: payload.summary ?? '' + } + case 'tool.call': + return { + id: row.id, + type: 'tool.call', + level: row.level, + message: row.message, + createdAt: row.createdAt, + toolName: payload.toolName ?? '' + } + } +} + +const uniqueTaskSessions = (state: PersistedDesktopState): TaskSession[] => { + const sessions = new Map<string, TaskSession>() + + for (const session of state.history) { + sessions.set(session.id, session) + } + + if (state.activeTask) { + sessions.set(state.activeTask.id, state.activeTask) + } + + return Array.from(sessions.values()) +} + +const fallbackWorkspace = (workspaceId: string): WorkspaceContext => ({ + id: workspaceId, + name: workspaceId, + rootPath: '', + shell: 'shell', + gitBranch: 'unknown', + gitStatusSummary: 'unknown', + envAllowList: ['PATH'], + recentTaskIds: [], + providerConfig: { + configuredAdapters: [] + } +}) + +export class SQLiteDesktopStore { + private database: DatabaseSyncInstance | null = null + private initialized = false + + public constructor( + private readonly databasePath: string, + private readonly legacyStore?: Pick<FileDesktopStore, 'read'> + ) {} + + public close(): void { + if (this.database) { + this.database.close() + this.database = null + } + + this.initialized = false + } + + public async read(): Promise<PersistedDesktopState | null> { + this.ensureInitialized() + + const settingsRow = this.getDatabase() + .prepare( + ` + SELECT + id, + schema_version AS schemaVersion, + selected_workspace_id AS selectedWorkspaceId, + telemetry_enabled AS telemetryEnabled, + release_channel AS releaseChannel, + active_task_id AS activeTaskId + FROM desktop_settings + WHERE id = ? + ` + ) + .get(desktopSettingsRecordId) as DesktopSettingsRow | undefined + + if (!settingsRow) { + const legacyState = this.legacyStore ? await this.legacyStore.read() : null + + if (!legacyState) { + return null + } + + await this.write(legacyState) + return this.read() + } + + return this.readPersistedState(settingsRow) + } + + public async write(state: PersistedDesktopState): Promise<void> { + this.ensureInitialized() + + if (state.workspaces.length === 0) { + throw new Error('SQLiteDesktopStore requires at least one workspace.') + } + + const database = this.getDatabase() + const timestamp = now() + const sessions = uniqueTaskSessions(state) + + database.exec('BEGIN IMMEDIATE;') + + try { + database.exec(` + DELETE FROM execution_events; + DELETE FROM desktop_settings; + DELETE FROM task_sessions; + DELETE FROM workspaces; + `) + + const insertWorkspace = database.prepare(` + INSERT INTO workspaces ( + id, + name, + root_path, + shell, + git_branch, + git_status_summary, + env_allow_list_json, + recent_task_ids_json, + provider_config_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + for (const workspace of state.workspaces) { + insertWorkspace.run( + workspace.id, + workspace.name, + workspace.rootPath, + workspace.shell, + workspace.gitBranch, + workspace.gitStatusSummary, + JSON.stringify(workspace.envAllowList), + JSON.stringify(workspace.recentTaskIds), + JSON.stringify(workspace.providerConfig ?? { configuredAdapters: [] }), + timestamp, + timestamp + ) + } + + const insertTaskSession = database.prepare(` + INSERT INTO task_sessions ( + id, + workspace_id, + adapter_id, + prompt, + status, + approved_by, + plan_json, + execution_summary, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const insertExecutionEvent = database.prepare(` + INSERT INTO execution_events ( + id, + task_session_id, + type, + level, + message, + payload_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + + for (const session of sessions) { + insertTaskSession.run( + session.id, + session.workspace.id, + session.adapterId, + session.prompt, + session.status, + session.approvedBy ?? null, + session.plan ? JSON.stringify(session.plan) : null, + session.executionSummary ?? null, + session.createdAt, + session.updatedAt + ) + + for (const event of session.events) { + insertExecutionEvent.run( + event.id, + session.id, + event.type, + event.level, + event.message, + serializeExecutionEventPayload(event), + event.createdAt + ) + } + } + + database + .prepare( + ` + INSERT INTO desktop_settings ( + id, + schema_version, + selected_workspace_id, + telemetry_enabled, + release_channel, + active_task_id, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ) + .run( + desktopSettingsRecordId, + currentDesktopSchemaVersion, + state.selectedWorkspaceId, + state.settings.telemetryEnabled ? 1 : 0, + state.settings.releaseChannel, + state.activeTask?.id ?? null, + timestamp, + timestamp + ) + + database.exec('COMMIT;') + } catch (error) { + database.exec('ROLLBACK;') + throw error + } + } + + private ensureInitialized(): void { + if (this.initialized) { + return + } + + const database = this.getDatabase() + const currentVersionRow = database.prepare('PRAGMA user_version').get() as Record< + string, + unknown + > + const currentVersionValue = currentVersionRow?.['user_version'] + const currentVersion = typeof currentVersionValue === 'number' ? currentVersionValue : 0 + + for (const migration of desktopSqliteMigrations) { + if (migration.version <= currentVersion) { + continue + } + + database.exec(readFileSync(migration.upFilePath, 'utf8')) + database.exec(`PRAGMA user_version = ${migration.version};`) + } + + this.initialized = true + } + + private getDatabase(): DatabaseSyncInstance { + if (!this.database) { + const database = new Database(this.databasePath) as DatabaseSyncInstance + database.exec('PRAGMA foreign_keys = ON;') + this.database = database + } + + return this.database + } + + private readPersistedState(settingsRow: DesktopSettingsRow): PersistedDesktopState { + const database = this.getDatabase() + const workspaces = database + .prepare( + ` + SELECT + id, + name, + root_path AS rootPath, + shell, + git_branch AS gitBranch, + git_status_summary AS gitStatusSummary, + env_allow_list_json AS envAllowListJson, + recent_task_ids_json AS recentTaskIdsJson, + provider_config_json AS providerConfigJson + FROM workspaces + ORDER BY rowid ASC + ` + ) + .all() as unknown as WorkspaceRow[] + const workspaceById = new Map<string, WorkspaceContext>() + + for (const row of workspaces) { + workspaceById.set(row.id, { + id: row.id, + name: row.name, + rootPath: row.rootPath, + shell: row.shell, + gitBranch: row.gitBranch, + gitStatusSummary: row.gitStatusSummary, + envAllowList: parseJson<string[]>(row.envAllowListJson, ['PATH']), + recentTaskIds: parseJson<string[]>(row.recentTaskIdsJson, []), + providerConfig: parseJson<NonNullable<WorkspaceContext['providerConfig']>>( + row.providerConfigJson, + { configuredAdapters: [] } + ) + }) + } + + const eventRows = database + .prepare( + ` + SELECT + id, + task_session_id AS taskSessionId, + type, + level, + message, + payload_json AS payloadJson, + created_at AS createdAt + FROM execution_events + ORDER BY created_at ASC, rowid ASC + ` + ) + .all() as unknown as ExecutionEventRow[] + const eventsByTaskSessionId = new Map<string, ExecutionEvent[]>() + + for (const row of eventRows) { + const events = eventsByTaskSessionId.get(row.taskSessionId) ?? [] + events.push(deserializeExecutionEvent(row)) + eventsByTaskSessionId.set(row.taskSessionId, events) + } + + const taskSessionRows = database + .prepare( + ` + SELECT + id, + workspace_id AS workspaceId, + adapter_id AS adapterId, + prompt, + status, + approved_by AS approvedBy, + plan_json AS planJson, + execution_summary AS executionSummary, + created_at AS createdAt, + updated_at AS updatedAt + FROM task_sessions + ORDER BY updated_at DESC, rowid DESC + ` + ) + .all() as unknown as TaskSessionRow[] + const history: TaskSession[] = taskSessionRows.map((row) => { + const plan = parseJson<NonNullable<TaskSession['plan']> | undefined>(row.planJson, undefined) + const taskSession: TaskSession = { + id: row.id, + adapterId: row.adapterId, + prompt: row.prompt, + status: row.status, + workspace: workspaceById.get(row.workspaceId) ?? fallbackWorkspace(row.workspaceId), + events: eventsByTaskSessionId.get(row.id) ?? [], + createdAt: row.createdAt, + updatedAt: row.updatedAt + } + + if (plan) { + taskSession.plan = plan + } + + if (row.executionSummary) { + taskSession.executionSummary = row.executionSummary + } + + if (row.approvedBy) { + taskSession.approvedBy = row.approvedBy + } + + return taskSession + }) + + const activeTask = history.find((session) => session.id === settingsRow.activeTaskId) ?? null + + return { + schemaVersion: settingsRow.schemaVersion ?? currentDesktopSchemaVersion, + activeTask, + history, + settings: { + telemetryEnabled: settingsRow.telemetryEnabled === 1, + releaseChannel: settingsRow.releaseChannel ?? defaultDesktopSettings.releaseChannel + }, + selectedWorkspaceId: + settingsRow.selectedWorkspaceId ?? history[0]?.workspace.id ?? workspaces[0]?.id ?? '', + workspaces: Array.from(workspaceById.values()) + } + } +} diff --git a/apps/desktop/src/main/persistence/sqliteMigrationCatalog.test.ts b/apps/desktop/src/main/persistence/sqliteMigrationCatalog.test.ts new file mode 100644 index 0000000..da14d75 --- /dev/null +++ b/apps/desktop/src/main/persistence/sqliteMigrationCatalog.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment node + +import { existsSync, readFileSync } from 'node:fs' + +import { + desktopSqliteMigrations, + getLatestDesktopSqliteMigrationVersion +} from './sqliteMigrationCatalog' + +describe('desktop sqlite migration catalog', () => { + it('keeps at least one ordered migration for the future SQLite store', () => { + expect(desktopSqliteMigrations.length).toBeGreaterThan(0) + expect(desktopSqliteMigrations[0]?.version).toBe(1) + expect(getLatestDesktopSqliteMigrationVersion()).toBe(desktopSqliteMigrations.at(-1)?.version) + }) + + it('ships both up and rollback SQL files for each registered migration', () => { + for (const migration of desktopSqliteMigrations) { + expect(existsSync(migration.upFilePath)).toBe(true) + expect(existsSync(migration.downFilePath)).toBe(true) + + const upSql = readFileSync(migration.upFilePath, 'utf8') + const downSql = readFileSync(migration.downFilePath, 'utf8') + + expect(upSql).toContain('BEGIN IMMEDIATE;') + expect(upSql).toContain('CREATE TABLE') + expect(downSql).toContain('BEGIN IMMEDIATE;') + expect(downSql).toContain('DROP TABLE') + } + }) +}) diff --git a/apps/desktop/src/main/persistence/sqliteMigrationCatalog.ts b/apps/desktop/src/main/persistence/sqliteMigrationCatalog.ts new file mode 100644 index 0000000..e117757 --- /dev/null +++ b/apps/desktop/src/main/persistence/sqliteMigrationCatalog.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from 'node:url' + +export interface DesktopSqliteMigration { + version: number + name: string + upFilePath: string + downFilePath: string +} + +const resolveMigrationFilePath = (fileName: string): string => + fileURLToPath(new URL(`./migrations/${fileName}`, import.meta.url)) + +export const desktopSqliteMigrations: DesktopSqliteMigration[] = [ + { + version: 1, + name: 'create_desktop_runtime_tables', + upFilePath: resolveMigrationFilePath('0001_create_desktop_runtime_tables.sql'), + downFilePath: resolveMigrationFilePath('0001_create_desktop_runtime_tables.rollback.sql') + } +] + +export const getLatestDesktopSqliteMigrationVersion = (): number => + desktopSqliteMigrations.at(-1)?.version ?? 0 diff --git a/apps/desktop/src/main/services/desktopController.test.ts b/apps/desktop/src/main/services/desktopController.test.ts new file mode 100644 index 0000000..fb97006 --- /dev/null +++ b/apps/desktop/src/main/services/desktopController.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + createPermissionRequest, + createWorkspaceContext, + type AgentAdapter, + type AgentPlanInput, + type ExecutionEvent, + type TaskPlan +} from '@mango/core' + +import { + currentDesktopSchemaVersion, + defaultDesktopSettings, + type DesktopStateStore, + type PersistedDesktopState +} from '../persistence/fileDesktopStore' +import { DesktopController } from './desktopController' + +class MemoryDesktopStore implements DesktopStateStore { + public constructor(public state: PersistedDesktopState | null) {} + + public async read(): Promise<PersistedDesktopState | null> { + return this.state + } + + public async write(state: PersistedDesktopState): Promise<void> { + this.state = state + } +} + +class FakeAdapter implements AgentAdapter { + public constructor( + public readonly id: string, + public readonly label: string, + private readonly executionMode: 'success' | 'failure' = 'success' + ) {} + + public async detectAvailability() { + return { + available: true, + command: this.id, + details: `${this.label} is ready` + } + } + + public async generatePlan(): Promise<TaskPlan> { + return { + headline: `${this.label} plan`, + summary: `Plan generated by ${this.label}.`, + steps: ['Inspect workspace', 'Generate plan', 'Execute task'], + requestedPermissions: [ + createPermissionRequest('shell', `Run ${this.label}`), + createPermissionRequest('filesystem', 'Update repository files') + ] + } + } + + public async runApprovedPlan(input: AgentPlanInput): Promise<ExecutionEvent[]> { + if (this.executionMode === 'failure') { + throw new Error(`${this.label} failed for ${input.sessionId}`) + } + + return [ + { + type: 'terminal.output', + id: `${input.sessionId}-${this.id}-evt-1`, + level: 'info', + message: `${this.label} executed the approved task`, + createdAt: '2026-03-25T00:00:00.000Z' + }, + { + type: 'summary.ready', + id: `${input.sessionId}-${this.id}-evt-2`, + level: 'info', + message: `${this.label} summary ready`, + summary: `${this.label} finished the approved task.`, + createdAt: '2026-03-25T00:00:01.000Z' + } + ] + } +} + +const workspace = createWorkspaceContext({ + id: 'workspace-main', + name: 'Mango', + rootPath: process.cwd(), + shell: 'powershell', + gitBranch: 'codex/mango-v1', + gitStatusSummary: 'clean' +}) + +const createPersistedState = (): PersistedDesktopState => ({ + schemaVersion: currentDesktopSchemaVersion, + activeTask: null, + history: [], + selectedWorkspaceId: workspace.id, + settings: defaultDesktopSettings, + workspaces: [workspace] +}) + +describe('DesktopController', () => { + it('uses the requested adapter and persists it as the workspace primary adapter', async () => { + const store = new MemoryDesktopStore(createPersistedState()) + const controller = new DesktopController(store, [ + new FakeAdapter('claude-code', 'Claude Code CLI'), + new FakeAdapter('mock-claude', 'Mock Claude CLI') + ]) + + const nextState = await controller.generatePlan({ + prompt: 'Implement adapter registry', + workspaceId: workspace.id, + adapterId: 'claude-code' + }) + + expect(nextState.activeTask?.adapterId).toBe('claude-code') + expect(nextState.activeTask?.plan?.headline).toBe('Claude Code CLI plan') + expect( + nextState.workspaces.find((entry) => entry.id === workspace.id)?.providerConfig + ?.primaryAdapterId + ).toBe('claude-code') + expect( + nextState.workspaces.find((entry) => entry.id === workspace.id)?.providerConfig + ?.configuredAdapters + ).toContain('claude-code') + }) + + it('marks the task as failed when the selected adapter crashes during execution', async () => { + const store = new MemoryDesktopStore(createPersistedState()) + const controller = new DesktopController(store, [ + new FakeAdapter('claude-code', 'Claude Code CLI', 'failure'), + new FakeAdapter('mock-claude', 'Mock Claude CLI') + ]) + + await controller.generatePlan({ + prompt: 'Implement adapter registry', + workspaceId: workspace.id, + adapterId: 'claude-code' + }) + + const nextState = await controller.approveAndRun() + + expect(nextState.activeTask?.adapterId).toBe('claude-code') + expect(nextState.activeTask?.status).toBe('failed') + expect(nextState.activeTask?.executionSummary).toContain('Claude Code CLI failed') + expect(nextState.activeTask?.events.at(-1)).toMatchObject({ + type: 'terminal.output', + level: 'error' + }) + }) +}) diff --git a/apps/desktop/src/main/services/desktopController.ts b/apps/desktop/src/main/services/desktopController.ts new file mode 100644 index 0000000..0271442 --- /dev/null +++ b/apps/desktop/src/main/services/desktopController.ts @@ -0,0 +1,333 @@ +import { execFileSync } from 'node:child_process' + +import { createDefaultAgentAdapters } from '@mango/adapters' +import type { DesktopState, GeneratePlanInput } from '@mango/contracts' +import { + applyTaskTransition, + appendExecutionEvent, + buildTaskReview, + createDefaultPermissionPolicy, + createTaskSession, + createWorkspaceContext, + evaluatePermissionRequests, + type AgentAdapter, + type TaskSession, + type WorkspaceContext +} from '@mango/core' + +import { + currentDesktopSchemaVersion, + defaultDesktopSettings, + type DesktopStateStore, + type PersistedDesktopState +} from '../persistence/fileDesktopStore' + +const countGitChanges = (rootPath: string): string => { + try { + const output = execFileSync('git', ['status', '--short'], { + cwd: rootPath, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() + + return output.length === 0 ? 'clean' : `${output.split(/\r?\n/).length} files changed` + } catch { + return 'git unavailable' + } +} + +const readGitBranch = (rootPath: string): string => { + try { + const output = execFileSync('git', ['branch', '--show-current'], { + cwd: rootPath, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() + + return output || 'detached' + } catch { + return 'no-git-branch' + } +} + +const detectShell = (): string => { + const shellPath = process.env.ComSpec ?? process.env.SHELL ?? 'shell' + return shellPath.split(/[\\/]/).pop() ?? shellPath +} + +const upsertHistory = (history: TaskSession[], session: TaskSession): TaskSession[] => [ + session, + ...history.filter((entry) => entry.id !== session.id) +] + +const buildFailureEvent = (sessionId: string, message: string) => ({ + type: 'terminal.output' as const, + id: `${sessionId}-failure-${Date.now().toString(36)}`, + level: 'error' as const, + message, + createdAt: new Date().toISOString() +}) + +const normalizeErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : 'Unknown adapter failure.' + +const upsertWorkspaceAdapter = ( + workspaces: WorkspaceContext[], + workspaceId: string, + adapterId: string +): WorkspaceContext[] => + workspaces.map((workspace) => + workspace.id !== workspaceId + ? workspace + : createWorkspaceContext({ + ...workspace, + providerConfig: { + primaryAdapterId: adapterId, + configuredAdapters: Array.from( + new Set([...(workspace.providerConfig?.configuredAdapters ?? []), adapterId]) + ) + } + }) + ) + +export class DesktopController { + private readonly permissionPolicy = createDefaultPermissionPolicy() + + public constructor( + private readonly store: DesktopStateStore, + private readonly adapters: AgentAdapter[] = createDefaultAgentAdapters() + ) {} + + public async bootstrap(): Promise<DesktopState> { + const persisted = await this.ensureState() + return this.buildDesktopState(persisted) + } + + public async generatePlan(input: GeneratePlanInput): Promise<DesktopState> { + const persisted = await this.ensureState() + const baseWorkspace = + persisted.workspaces.find((entry) => entry.id === input.workspaceId) ?? + persisted.workspaces[0] ?? + this.detectDefaultWorkspace() + const adapter = await this.requireAdapter(input.adapterId) + const workspace = createWorkspaceContext({ + ...baseWorkspace, + providerConfig: { + primaryAdapterId: adapter.id, + configuredAdapters: Array.from( + new Set([...(baseWorkspace.providerConfig?.configuredAdapters ?? []), adapter.id]) + ) + } + }) + + let session = createTaskSession({ + adapterId: adapter.id, + prompt: input.prompt, + workspace + }) + + const plan = await adapter.generatePlan({ + sessionId: session.id, + prompt: session.prompt, + workspace + }) + + session = applyTaskTransition(session, { + type: 'plan-generated', + plan + }) + + const nextState: PersistedDesktopState = { + ...persisted, + schemaVersion: currentDesktopSchemaVersion, + activeTask: session, + history: upsertHistory(persisted.history, session), + selectedWorkspaceId: workspace.id, + workspaces: upsertWorkspaceAdapter(persisted.workspaces, workspace.id, adapter.id) + } + + await this.store.write(nextState) + return this.buildDesktopState(nextState) + } + + public async approveAndRun(): Promise<DesktopState> { + const persisted = await this.ensureState() + + if (!persisted.activeTask?.plan) { + return this.buildDesktopState(persisted) + } + + let session = persisted.activeTask + + if (session.status === 'planned') { + session = applyTaskTransition(session, { + type: 'approved', + approvedBy: 'desktop-user' + }) + } + + if (session.status === 'approved') { + session = applyTaskTransition(session, { + type: 'execution-started' + }) + } + + try { + const adapter = await this.requireAdapter(session.adapterId) + const events = await adapter.runApprovedPlan({ + sessionId: session.id, + prompt: session.prompt, + workspace: session.workspace + }) + + for (const event of events) { + session = appendExecutionEvent(session, event) + } + + const finalSummary = + events.find( + (event): event is Extract<(typeof events)[number], { type: 'summary.ready' }> => + event.type === 'summary.ready' + )?.summary ?? 'Execution completed.' + + session = applyTaskTransition(session, { + type: 'execution-finished', + summary: finalSummary + }) + } catch (error) { + const failureSummary = `${session.adapterId} failed: ${normalizeErrorMessage(error)}` + session = appendExecutionEvent(session, buildFailureEvent(session.id, failureSummary)) + session = applyTaskTransition(session, { + type: 'execution-failed', + summary: failureSummary + }) + } + + const nextState: PersistedDesktopState = { + ...persisted, + schemaVersion: currentDesktopSchemaVersion, + activeTask: session, + history: upsertHistory(persisted.history, session) + } + + await this.store.write(nextState) + return this.buildDesktopState(nextState) + } + + private async ensureState(): Promise<PersistedDesktopState> { + const persisted = await this.store.read() + + if (persisted) { + const refreshedWorkspaces = persisted.workspaces.map((workspace) => + this.refreshWorkspace(workspace) + ) + return { + ...persisted, + workspaces: refreshedWorkspaces + } + } + + const workspace = this.detectDefaultWorkspace() + + const initialState: PersistedDesktopState = { + schemaVersion: currentDesktopSchemaVersion, + activeTask: null, + history: [], + selectedWorkspaceId: workspace.id, + settings: defaultDesktopSettings, + workspaces: [workspace] + } + + await this.store.write(initialState) + + return initialState + } + + private detectDefaultWorkspace(): WorkspaceContext { + const rootPath = process.env.MANGO_WORKSPACE_ROOT ?? process.cwd() + + return createWorkspaceContext({ + id: 'workspace-main', + name: 'Active workspace', + rootPath, + shell: detectShell(), + gitBranch: readGitBranch(rootPath), + gitStatusSummary: countGitChanges(rootPath) + }) + } + + private refreshWorkspace(workspace: WorkspaceContext): WorkspaceContext { + return createWorkspaceContext({ + ...workspace, + shell: detectShell(), + gitBranch: readGitBranch(workspace.rootPath), + gitStatusSummary: countGitChanges(workspace.rootPath) + }) + } + + private async buildDesktopState(persisted: PersistedDesktopState): Promise<DesktopState> { + const adapterStatuses = await Promise.all( + this.adapters.map(async (adapter) => { + try { + const availability = await adapter.detectAvailability() + + return { + id: adapter.id, + label: adapter.label, + available: availability.available, + details: availability.details + } + } catch (error) { + return { + id: adapter.id, + label: adapter.label, + available: false, + details: normalizeErrorMessage(error) + } + } + }) + ) + const activeTask = persisted.activeTask + const currentPermissions = activeTask?.plan + ? evaluatePermissionRequests(this.permissionPolicy, activeTask.plan.requestedPermissions) + : [] + const selectedWorkspaceId = + persisted.selectedWorkspaceId ?? + persisted.workspaces[0]?.id ?? + this.detectDefaultWorkspace().id + + return { + productName: 'Mango', + slogan: 'You Plan, Mango Goes.', + adapters: adapterStatuses, + workspaces: persisted.workspaces, + selectedWorkspaceId, + activeTask, + currentPermissions, + review: activeTask ? buildTaskReview(activeTask) : null, + history: persisted.history, + settings: persisted.settings, + environment: { + platform: process.platform, + shell: detectShell(), + autoUpdateConfigured: true + } + } + } + + private async requireAdapter(adapterId: string): Promise<AgentAdapter> { + const adapter = this.adapters.find((entry) => entry.id === adapterId) + + if (!adapter) { + throw new Error(`Adapter "${adapterId}" is not registered.`) + } + + const availability = await adapter.detectAvailability() + + if (!availability.available) { + throw new Error(availability.details) + } + + return adapter + } +} diff --git a/apps/desktop/src/preload/api/desktopApi.ts b/apps/desktop/src/preload/api/desktopApi.ts new file mode 100644 index 0000000..cb095c5 --- /dev/null +++ b/apps/desktop/src/preload/api/desktopApi.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +import { MANGO_DESKTOP_CHANNELS, type DesktopApi, type GeneratePlanInput } from '@mango/contracts' + +export const createDesktopApi = (): DesktopApi => ({ + bootstrap: () => ipcRenderer.invoke(MANGO_DESKTOP_CHANNELS.bootstrap), + generatePlan: (input: GeneratePlanInput) => + ipcRenderer.invoke(MANGO_DESKTOP_CHANNELS.generatePlan, input), + approveAndRun: () => ipcRenderer.invoke(MANGO_DESKTOP_CHANNELS.approveAndRun), + openFeedback: () => ipcRenderer.invoke(MANGO_DESKTOP_CHANNELS.openFeedback) +}) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts new file mode 100644 index 0000000..c2afa6f --- /dev/null +++ b/apps/desktop/src/preload/index.ts @@ -0,0 +1,5 @@ +import { contextBridge } from 'electron' + +import { createDesktopApi } from './api/desktopApi' + +contextBridge.exposeInMainWorld('mangoDesktop', createDesktopApi()) diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html new file mode 100644 index 0000000..4560f5e --- /dev/null +++ b/apps/desktop/src/renderer/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Mango + + +
+ + + diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx new file mode 100644 index 0000000..8aed8a0 --- /dev/null +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -0,0 +1,121 @@ +import { startTransition, useEffect, useState } from 'react' + +import type { DesktopState } from '@mango/contracts' + +import { TaskWorkbench } from '../features/task-workbench/TaskWorkbench' +import { getDesktopApi } from '../lib/desktopApi' + +const resolvePreferredAdapterId = (state: DesktopState): string => { + const selectedWorkspace = + state.workspaces.find((workspace) => workspace.id === state.selectedWorkspaceId) ?? + state.workspaces[0] + const preferredAdapterId = selectedWorkspace?.providerConfig?.primaryAdapterId + const preferredAdapter = state.adapters.find( + (adapter) => adapter.id === preferredAdapterId && adapter.available + ) + + return ( + preferredAdapter?.id ?? + state.adapters.find((adapter) => adapter.available)?.id ?? + preferredAdapterId ?? + state.adapters[0]?.id ?? + 'mock-claude' + ) +} + +export const App = () => { + const [state, setState] = useState(null) + const [prompt, setPrompt] = useState( + 'Plan the next Mango milestone, request visible approvals, then execute the approved batch and summarize the output.' + ) + const [busy, setBusy] = useState(false) + const [selectedAdapterId, setSelectedAdapterId] = useState('mock-claude') + + useEffect(() => { + let mounted = true + + void getDesktopApi() + .bootstrap() + .then((nextState) => { + if (mounted) { + startTransition(() => { + setState(nextState) + setSelectedAdapterId(resolvePreferredAdapterId(nextState)) + }) + } + }) + + return () => { + mounted = false + } + }, []) + + useEffect(() => { + if (!state) { + return + } + + setSelectedAdapterId((current) => + state.adapters.some((adapter) => adapter.id === current && adapter.available) + ? current + : resolvePreferredAdapterId(state) + ) + }, [state]) + + const handlePlan = async () => { + if (!state) { + return + } + + setBusy(true) + + try { + const nextState = await getDesktopApi().generatePlan({ + prompt, + workspaceId: state.selectedWorkspaceId, + adapterId: selectedAdapterId || resolvePreferredAdapterId(state) + }) + + startTransition(() => { + setState(nextState) + setSelectedAdapterId(resolvePreferredAdapterId(nextState)) + }) + } finally { + setBusy(false) + } + } + + const handleApproveAndRun = async () => { + setBusy(true) + + try { + const nextState = await getDesktopApi().approveAndRun() + + startTransition(() => { + setState(nextState) + }) + } finally { + setBusy(false) + } + } + + if (!state) { + return
Starting Mango...
+ } + + return ( + void getDesktopApi().openFeedback()} + onGeneratePlan={handlePlan} + onPromptChange={setPrompt} + prompt={prompt} + selectedAdapterId={selectedAdapterId} + state={state} + /> + ) +} + +export default App diff --git a/apps/desktop/src/renderer/src/app/fixtures.ts b/apps/desktop/src/renderer/src/app/fixtures.ts new file mode 100644 index 0000000..cd7e9ad --- /dev/null +++ b/apps/desktop/src/renderer/src/app/fixtures.ts @@ -0,0 +1,138 @@ +import type { DesktopState } from '@mango/contracts' +import { + applyTaskTransition, + appendExecutionEvent, + buildTaskReview, + createDefaultPermissionPolicy, + createPermissionRequest, + createTaskSession, + createWorkspaceContext, + evaluatePermissionRequests, + type TaskSession, + type WorkspaceContext +} from '@mango/core' + +interface MockDesktopStateOptions { + adapters?: DesktopState['adapters'] + workspaces?: WorkspaceContext[] +} + +const buildPlannedTask = (workspace: WorkspaceContext): TaskSession => { + let session = createTaskSession({ + adapterId: 'mock-claude', + prompt: + 'Map the current repository, propose the first implementation batch, and execute it safely.', + workspace + }) + + session = applyTaskTransition(session, { + type: 'plan-generated', + plan: { + headline: 'Initial Mango bootstrap', + summary: 'Create the core packages, wire the desktop shell, and prepare release foundations.', + steps: [ + 'Inspect the repository state', + 'Draft the first implementation batch', + 'Request approval for filesystem, shell, and network access', + 'Execute the approved plan and summarize the results' + ], + requestedPermissions: [ + createPermissionRequest('shell', 'Run local verification commands'), + createPermissionRequest('filesystem', 'Create the Mango monorepo'), + createPermissionRequest('network', 'Fetch package metadata and release assets') + ] + } + }) + + session = applyTaskTransition(session, { + type: 'approved', + approvedBy: 'will' + }) + + session = applyTaskTransition(session, { + type: 'execution-started' + }) + + session = appendExecutionEvent(session, { + type: 'terminal.output', + id: 'fixture-output', + level: 'info', + message: 'pnpm verify', + createdAt: '2026-03-23T10:00:00.000Z' + }) + + session = appendExecutionEvent(session, { + type: 'file.change', + id: 'fixture-file', + level: 'info', + message: 'Created apps/desktop/src/main/app/bootstrapDesktopApp.ts', + createdAt: '2026-03-23T10:01:00.000Z', + filePath: 'apps/desktop/src/main/app/bootstrapDesktopApp.ts' + }) + + session = appendExecutionEvent(session, { + type: 'summary.ready', + id: 'fixture-summary', + level: 'info', + message: 'Execution summary ready', + createdAt: '2026-03-23T10:02:00.000Z', + summary: + 'Mango generated the plan, executed the first batch, and prepared the release checklist.' + }) + + session = applyTaskTransition(session, { + type: 'execution-finished', + summary: + 'Mango generated the plan, executed the first batch, and prepared the release checklist.' + }) + + return session +} + +export const buildMockDesktopState = (options: MockDesktopStateOptions = {}): DesktopState => { + const workspace = + options.workspaces?.[0] ?? + createWorkspaceContext({ + id: 'workspace-main', + name: 'Mango', + rootPath: 'D:/willxue/FruitsAI/Mango', + shell: 'powershell', + gitBranch: 'codex/mango-v1', + gitStatusSummary: 'clean' + }) + + const task = buildPlannedTask(workspace) + const review = buildTaskReview(task) + + return { + productName: 'Mango', + slogan: 'You Plan, Mango Goes.', + adapters: options.adapters ?? [ + { + id: 'mock-claude', + label: 'Mock Claude CLI', + available: true, + details: 'Local bundled adapter for rapid product iteration.' + } + ], + workspaces: options.workspaces ?? [workspace], + selectedWorkspaceId: workspace.id, + activeTask: task, + currentPermissions: evaluatePermissionRequests(createDefaultPermissionPolicy(), [ + createPermissionRequest('shell', 'Run project checks'), + createPermissionRequest('filesystem', 'Create the first Mango source files'), + createPermissionRequest('network', 'Reach package and release metadata') + ]), + review, + history: [task], + settings: { + telemetryEnabled: true, + releaseChannel: 'beta' + }, + environment: { + platform: 'win32', + shell: workspace.shell, + autoUpdateConfigured: true + } + } +} diff --git a/apps/desktop/src/renderer/src/components/README.md b/apps/desktop/src/renderer/src/components/README.md new file mode 100644 index 0000000..874814d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/README.md @@ -0,0 +1,7 @@ +# 组件目录说明 + +此目录只放跨 feature 复用的展示组件。 + +- 不直接读取 Electron 或 Node 能力 +- 不持有复杂业务状态机 +- 只通过 props 接收数据与回调 diff --git a/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx b/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx new file mode 100644 index 0000000..3ddd99e --- /dev/null +++ b/apps/desktop/src/renderer/src/features/task-workbench/TaskWorkbench.tsx @@ -0,0 +1,266 @@ +import type { DesktopState } from '@mango/contracts' + +interface TaskWorkbenchProps { + state: DesktopState + prompt?: string + busy?: boolean + selectedAdapterId?: string + onAdapterChange?: (value: string) => void + onPromptChange?: (value: string) => void + onGeneratePlan?: () => void + onApproveAndRun?: () => void + onFeedback?: () => void +} + +const statusCopy: Record['status'], string> = { + draft: 'Waiting for a plan', + planned: 'Plan ready for review', + approved: 'Approved and queued', + running: 'Executing on your behalf', + succeeded: 'Result ready to review', + failed: 'Execution needs attention', + cancelled: 'Execution stopped by the user' +} + +const laneLabels = ['Plan', 'Approve', 'Go', 'Review'] + +const formatStatus = (state: DesktopState): string => + state.activeTask ? statusCopy[state.activeTask.status] : 'No task yet' + +const resolveSelectedAdapter = (state: DesktopState, selectedAdapterId?: string) => + state.adapters.find((adapter) => adapter.id === selectedAdapterId) ?? + state.adapters.find((adapter) => adapter.available) ?? + state.adapters[0] + +export const TaskWorkbench = ({ + state, + prompt = 'Ship the first Mango milestone: scaffold the desktop workspace, wire the task engine, and prepare the release pipeline.', + busy = false, + selectedAdapterId, + onAdapterChange, + onPromptChange, + onGeneratePlan, + onApproveAndRun, + onFeedback +}: TaskWorkbenchProps) => { + const selectedWorkspace = + state.workspaces.find((workspace) => workspace.id === state.selectedWorkspaceId) ?? + state.workspaces[0] + const selectedAdapter = resolveSelectedAdapter( + state, + selectedAdapterId ?? selectedWorkspace?.providerConfig?.primaryAdapterId + ) + const activePlan = state.activeTask?.plan + + return ( +
+ + +
+
+
+

Desktop Agent Workbench

+

You Plan, Mango Goes.

+

+ A citrus-bright control room for planning, approving, executing, and reviewing + developer tasks without surrendering visibility. +

+
+
{formatStatus(state)}
+
+ +
+
+

Execution loop

+ {selectedAdapter?.label ?? 'No adapter selected'} +
+
+ {laneLabels.map((label, index) => ( +
+ {`0${index + 1}`} + {label} +
+ ))} +
+
+ +
+
+
+
+

Task intake

+

Brief Mango on the next move

+
+ +
+