diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 159e973c..dc679363 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -63,48 +63,9 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # publish-docker: - # runs-on: ubuntu-latest - # needs: publish-npm - # if: ${{ secrets.DOCKERHUB_TOKEN != '' }} - # steps: - # - uses: actions/checkout@v4 - # - name: Extract version from tag - # id: version - # run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" - # - uses: docker/setup-buildx-action@v3 - # - uses: docker/login-action@v3 - # with: - # username: ${{ secrets.DOCKERHUB_USERNAME }} - # password: ${{ secrets.DOCKERHUB_TOKEN }} - # - uses: docker/build-push-action@v6 - # with: - # context: . - # push: true - # tags: | - # markus/markus:${{ steps.version.outputs.VERSION }} - # markus/markus:latest - # cache-from: type=gha - # cache-to: type=gha,mode=max - - build-binaries: + build-server-binary: needs: publish-npm - strategy: - matrix: - include: - - os: ubuntu-latest - platform: linux - arch: x64 - - os: macos-latest - platform: darwin - arch: arm64 - - os: macos-latest - platform: darwin - arch: x64 - - os: windows-latest - platform: win - arch: x64 - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -128,106 +89,23 @@ jobs: - name: Bundle CLI for publishing run: pnpm --filter @markus-global/cli build:bundle - - name: Import Apple certificates - if: matrix.platform == 'darwin' - env: - APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - run: | - if [ -z "$APPLE_CERTIFICATE_P12" ]; then - echo "⚠ Apple signing secrets not configured — skipping" - exit 0 - fi - - KEYCHAIN="build-$$.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -hex 12)" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - security set-keychain-settings -lut 900 "$KEYCHAIN" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - - echo "$APPLE_CERTIFICATE_P12" | base64 --decode > /tmp/cert.p12 - security import /tmp/cert.p12 -k "$KEYCHAIN" -P "$APPLE_CERTIFICATE_PASSWORD" \ - -T /usr/bin/productsign -T /usr/bin/codesign - security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - rm /tmp/cert.p12 - - # Export identities for later steps - APP_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)"/\1/' || true) - INST_IDENTITY=$(security find-identity -v -p basic "$KEYCHAIN" | grep "Developer ID Installer" | head -1 | sed 's/.*"\(.*\)"/\1/' || true) - - echo "APPLE_KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" - echo "APPLE_KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" - [ -n "$APP_IDENTITY" ] && echo "MACOS_CODESIGN_IDENTITY=$APP_IDENTITY" >> "$GITHUB_ENV" && echo "✓ Found Application identity: $APP_IDENTITY" - [ -n "$INST_IDENTITY" ] && echo "MACOS_INSTALLER_IDENTITY=$INST_IDENTITY" >> "$GITHUB_ENV" && echo "✓ Found Installer identity: $INST_IDENTITY" - - if [ -z "$APP_IDENTITY" ]; then - echo "⚠ No 'Developer ID Application' certificate found — binaries will NOT be codesigned" - echo " Notarization will likely fail. Add a Developer ID Application cert to the P12." - fi - - name: Build binary archive - run: bash scripts/build-binary.sh ${{ matrix.platform }} ${{ matrix.arch }} - - - name: Sign and notarize macOS .pkg - if: matrix.platform == 'darwin' && env.MACOS_INSTALLER_IDENTITY != '' - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: | - VERSION=$(node -p "require('./package.json').version") - PKG="dist-binary/markus-v${VERSION}-darwin-${{ matrix.arch }}.pkg" - PKG_SIGNED="dist-binary/markus-v${VERSION}-darwin-${{ matrix.arch }}-signed.pkg" - FIXED="dist-binary/markus-setup-darwin-${{ matrix.arch }}.pkg" - - echo "Signing .pkg with: $MACOS_INSTALLER_IDENTITY" - productsign --sign "$MACOS_INSTALLER_IDENTITY" --keychain "$APPLE_KEYCHAIN" "$PKG" "$PKG_SIGNED" - mv "$PKG_SIGNED" "$PKG" - cp "$PKG" "$FIXED" - - # Notarize (non-fatal: .pkg is still usable without notarization) - SUBMISSION_OUT=$(xcrun notarytool submit "$PKG" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_ID_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ - --wait --timeout 600 2>&1) || true - echo "$SUBMISSION_OUT" - - SUBMISSION_ID=$(echo "$SUBMISSION_OUT" | grep "id:" | head -1 | awk '{print $2}') - if echo "$SUBMISSION_OUT" | grep -q "status: Invalid"; then - echo "⚠ Notarization failed — fetching log for details:" - xcrun notarytool log "$SUBMISSION_ID" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_ID_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" 2>&1 || true - echo "⚠ .pkg is signed but NOT notarized. Users will see a Gatekeeper warning." - echo " To fix: add a 'Developer ID Application' cert to APPLE_CERTIFICATE_P12." - else - # Staple the notarization ticket - xcrun stapler staple "$PKG" - cp "$PKG" "$FIXED" - echo "✓ Signed and notarized: $PKG" - fi - - - name: Cleanup Apple keychain - if: always() && matrix.platform == 'darwin' && env.APPLE_KEYCHAIN != '' - run: security delete-keychain "$APPLE_KEYCHAIN" 2>/dev/null || true + run: bash scripts/build-binary.sh linux x64 - name: Upload versioned installer uses: actions/upload-artifact@v4 with: - name: markus-${{ matrix.platform }}-${{ matrix.arch }} + name: markus-linux-x64 path: | - dist-binary/markus-v*-${{ matrix.platform }}-${{ matrix.arch }}.* - !dist-binary/markus-v*-${{ matrix.platform }}-${{ matrix.arch }}/ + dist-binary/markus-v*-linux-x64.* + !dist-binary/markus-v*-linux-x64/ retention-days: 5 - - name: Upload fixed-name installer (for /releases/latest/download/) + - name: Upload fixed-name installer uses: actions/upload-artifact@v4 with: - name: markus-setup-${{ matrix.platform }}-${{ matrix.arch }} - path: dist-binary/markus-setup-${{ matrix.platform }}-${{ matrix.arch }}.* + name: markus-setup-linux-x64 + path: dist-binary/markus-setup-linux-x64.* retention-days: 5 build-desktop: @@ -306,6 +184,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Cleanup Apple keychain @@ -318,98 +197,13 @@ jobs: name: markus-desktop-${{ matrix.platform }}-${{ matrix.arch }} path: | packages/desktop/dist-electron/*.dmg - packages/desktop/dist-electron/*.zip packages/desktop/dist-electron/*.exe packages/desktop/dist-electron/*.AppImage - packages/desktop/dist-electron/*.deb retention-days: 5 - build-desktop-mas: - needs: publish-npm - if: needs.publish-npm.outputs.is_prerelease == 'false' - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 9 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - name: Build all packages - run: pnpm build - - - name: Build Web UI - run: pnpm --filter @markus/web-ui build - - - name: Build Electron app (MAS) - run: MARKUS_MAS=true pnpm --filter @markus/desktop build:electron - - - name: Import MAS certificates - env: - MAS_CERTIFICATE_P12: ${{ secrets.MAS_CERTIFICATE_P12 }} - MAS_CERTIFICATE_PASSWORD: ${{ secrets.MAS_CERTIFICATE_PASSWORD }} - MAS_PROVISIONING_PROFILE: ${{ secrets.MAS_PROVISIONING_PROFILE }} - run: | - if [ -z "$MAS_CERTIFICATE_P12" ]; then - echo "⚠ MAS signing secrets not configured — skipping" - exit 0 - fi - KEYCHAIN="mas-$$.keychain-db" - KEYCHAIN_PASSWORD="$(openssl rand -hex 12)" - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - security set-keychain-settings -lut 900 "$KEYCHAIN" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - echo "$MAS_CERTIFICATE_P12" | base64 --decode > /tmp/cert.p12 - security import /tmp/cert.p12 -k "$KEYCHAIN" -P "$MAS_CERTIFICATE_PASSWORD" \ - -T /usr/bin/codesign - security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - rm /tmp/cert.p12 - # Install provisioning profile - if [ -n "$MAS_PROVISIONING_PROFILE" ]; then - echo "$MAS_PROVISIONING_PROFILE" | base64 -d > packages/desktop/build/markus.provisionprofile - fi - echo "MAS_KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" - - - name: Package MAS app - working-directory: packages/desktop - run: pnpm dist:mas - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload to App Store Connect - if: env.MAS_KEYCHAIN != '' - env: - ASC_API_KEY: ${{ secrets.ASC_API_KEY }} - ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} - ASC_API_ISSUER: ${{ secrets.ASC_API_ISSUER }} - run: | - if [ -z "$ASC_API_KEY" ]; then - echo "⚠ App Store Connect API key not configured — skipping upload" - exit 0 - fi - mkdir -p ~/.private_keys - echo "$ASC_API_KEY" | base64 -d > ~/.private_keys/AuthKey_${ASC_API_KEY_ID}.p8 - PKG_FILE=$(find packages/desktop/dist-electron -name "*.pkg" | head -1) - if [ -n "$PKG_FILE" ]; then - xcrun altool --upload-app --type macos -f "$PKG_FILE" \ - --apiKey "$ASC_API_KEY_ID" --apiIssuer "$ASC_API_ISSUER" - fi - - - name: Cleanup MAS keychain - if: always() && env.MAS_KEYCHAIN != '' - run: security delete-keychain "$MAS_KEYCHAIN" 2>/dev/null || true - github-release: runs-on: ubuntu-latest - needs: [publish-npm, build-binaries, build-desktop] + needs: [publish-npm, build-server-binary, build-desktop] steps: - uses: actions/checkout@v4 @@ -417,7 +211,7 @@ jobs: id: version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" - - name: Download all binary artifacts + - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts @@ -437,40 +231,25 @@ jobs: body: | ## Desktop App (recommended) - Native application with auto-update, notifications, and full agent capabilities: - | Platform | Download | |----------|----------| - | macOS Apple Silicon | [Markus-darwin-arm64.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}-arm64.dmg) | - | macOS Intel | [Markus-darwin-x64.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.dmg) | - | Windows x64 | [Markus-win-x64-setup.exe](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-Setup-${{ steps.version.outputs.VERSION }}.exe) | - | Linux x64 | [Markus-linux-x64.AppImage](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.AppImage) | - - Also available on the [Mac App Store](https://apps.apple.com/app/markus/id0000000000) (some features restricted). - - ## Server / CLI Install - - **Binary installer** (includes Node.js runtime): + | macOS (Apple Silicon) | [Markus.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}-arm64.dmg) | + | macOS (Intel) | [Markus.dmg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.dmg) | + | Windows x64 | [Markus-Setup.exe](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-Setup-${{ steps.version.outputs.VERSION }}.exe) | + | Linux x64 | [Markus.AppImage](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/Markus-${{ steps.version.outputs.VERSION }}.AppImage) | - | Platform | Installer | - |----------|-----------| - | Windows x64 | [markus-setup-win-x64.exe](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-win-x64.exe) | - | macOS Apple Silicon | [markus-setup-darwin-arm64.pkg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-darwin-arm64.pkg) | - | macOS Intel | [markus-setup-darwin-x64.pkg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-darwin-x64.pkg) | - | Linux x64 (.deb) | [markus-setup-linux-x64.deb](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-linux-x64.deb) | + ## Server / CLI - **Or via terminal:** ```bash - # macOS / Linux + # macOS / Linux (one-liner) curl -fsSL https://markus.global/install.sh | bash - # Windows (PowerShell) - irm https://markus.global/install.ps1 | iex - - # npm (if you already have Node.js 22+) + # npm (requires Node.js 22+) npm install -g @markus-global/cli@${{ steps.version.outputs.VERSION }} ``` + Linux server binary: [markus-setup-linux-x64.deb](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-linux-x64.deb) + - name: Create GitHub Release (pre-release) if: needs.publish-npm.outputs.is_prerelease == 'true' uses: softprops/action-gh-release@v2 @@ -480,40 +259,24 @@ jobs: generate_release_notes: true files: artifacts/* body: | - > **Pre-release** — this version is for testing. Use `npm i -g @markus-global/cli@next` to install. - > The `install.sh` one-liner always installs the latest stable release. - - ## Install (testers) + > **Pre-release** — for testing only. `install.sh` always installs the latest stable. ```bash - # npm (recommended for RC testing) npm install -g @markus-global/cli@${{ steps.version.outputs.VERSION }} - - # or use the @next tag - npm install -g @markus-global/cli@next ``` - ## Binary installers - - | Platform | Installer | - |----------|-----------| - | Windows x64 | [markus-setup-win-x64.exe](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-win-x64.exe) | - | macOS Apple Silicon | [markus-setup-darwin-arm64.pkg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-darwin-arm64.pkg) | - | macOS Intel | [markus-setup-darwin-x64.pkg](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-darwin-x64.pkg) | - | Linux x64 (.deb) | [markus-setup-linux-x64.deb](https://github.com/markus-global/markus/releases/download/v${{ steps.version.outputs.VERSION }}/markus-setup-linux-x64.deb) | - upload-to-hub: runs-on: ubuntu-latest - needs: [publish-npm, build-binaries, github-release] + needs: [publish-npm, build-server-binary, build-desktop, github-release] if: needs.publish-npm.outputs.is_prerelease == 'false' steps: - - name: Download all binary artifacts + - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true - - name: Upload binaries directly to R2 + - name: Upload to R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} @@ -523,7 +286,7 @@ jobs: run: | set -e uploaded=0 - for file in artifacts/markus-setup-* artifacts/markus-v${VERSION}-*; do + for file in artifacts/markus-setup-* artifacts/markus-v${VERSION}-* artifacts/Markus-*.dmg artifacts/Markus-*.exe artifacts/Markus-*.AppImage; do [ -f "$file" ] || continue filename=$(basename "$file") echo "Uploading $filename to R2..." @@ -534,7 +297,7 @@ jobs: done if [ "$uploaded" -eq 0 ]; then - echo "::error::No binary artifacts found to upload" + echo "::error::No artifacts found to upload" exit 1 fi diff --git a/README.md b/README.md index f4e52a48..aefd1899 100644 --- a/README.md +++ b/README.md @@ -62,18 +62,24 @@ Unlike agent orchestrators that dispatch tasks to external CLI tools, **Markus i ## Quick Start -```bash -# Install -curl -fsSL https://markus.global/install.sh | bash -# or: npm install -g @markus-global/cli +**Desktop App** — download from [Releases](https://github.com/markus-global/markus/releases/latest) (macOS `.dmg` / Windows `.exe` / Linux `.AppImage`) + +**Command line** (requires Node.js 22+): -# Launch +```bash +npm install -g @markus-global/cli markus start ``` +**Linux one-liner** (works without Node.js): + +```bash +curl -fsSL https://markus.global/install.sh | bash +``` + Open **http://localhost:8056** — the onboarding wizard will guide you to set up your name, email, and password. Initial login: `admin@markus.local` / `markus123`. -That's it. SQLite database, bundled web UI, zero dependencies to install separately. +That's it. SQLite database, bundled web UI, zero external dependencies. > **From source:** `git clone https://github.com/markus-global/markus.git && cd markus && pnpm install && pnpm build && pnpm dev` diff --git a/README.zh-CN.md b/README.zh-CN.md index 902889f9..b63a9068 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,15 +61,21 @@ Markus 是一个**运行完整 AI 团队的开源平台** — 不是对其他智 ## 快速开始 -```bash -# 安装 -curl -fsSL https://markus.global/install.sh | bash -# 或者: npm install -g @markus-global/cli +**桌面应用** — 从 [Releases](https://github.com/markus-global/markus/releases/latest) 下载(macOS `.dmg` / Windows `.exe` / Linux `.AppImage`) + +**命令行安装**(需要 Node.js 22+): -# 启动 +```bash +npm install -g @markus-global/cli markus start ``` +**Linux 一键安装**(无需 Node.js): + +```bash +curl -fsSL https://markus.global/install.sh | bash +``` + 打开 **http://localhost:8056** — 引导向导将引导你设置姓名、邮箱和密码。初始登录:`admin@markus.local` / `markus123`。 就这样。SQLite 数据库,内置 Web 界面,无需额外安装任何依赖。 diff --git a/RELEASELOG.md b/RELEASELOG.md index c18dedea..4ae6fa44 100644 --- a/RELEASELOG.md +++ b/RELEASELOG.md @@ -1,5 +1,206 @@ # Release Log +## v0.8.4 + +Electron 桌面应用;智能模型路由(多模态模型发现 + 区域提供商拆分 + 路由 UI);Free 计划更名 Community 并提升上限;CI Desktop 构建修复;R2 上传 Electron 产物。 + +### New Features + +- **Electron 桌面应用** — 全新 `packages/desktop`,支持 macOS/Windows/Linux;启动闪屏、系统通知(仅关键事件)、应用菜单中英双语 i18n、macOS 签名与公证 +- **智能模型路由** — 动态提供商 schema 发现、多模态模型自动检测(所有提供商)、区域提供商拆分、路由 UI 与定价同步 +- **Community 计划** — Free 计划更名为 Community,提升 Agent 和团队数量上限 + +### Bug Fixes + +- **修复 Desktop 构建 CI** — 添加 `APPLE_APP_SPECIFIC_PASSWORD` 环境变量解决 macOS 公证失败;Linux `executableName` 设为 `markus` 解决 AppImage 包名含 `@` 非法字符 +- **修复 R2 上传遗漏 Desktop 产物** — `upload-to-hub` 增加 `build-desktop` 依赖,上传 `.dmg`/`.exe`/`.AppImage` 到 R2 +- **修复暗色主题与 traffic light** — 冷中性暗色主题、macOS 窗口交通灯动态定位 +- **修复交付物时间戳与聊天滚动** — deliverable 时间戳稳定性、聊天滚动、桌面通知导航 +- **修复飞书 dirname 未定义** — Lark adapter `__dirname` 引用修复 +- **修复 Agent 初始状态** — 新 Agent 初始状态改为 offline 而非 idle;desired-state 与 runtime-state 分离确保重启持久化 +- **修复模型路由多模态兼容** — 多模态 provider API 兼容性问题、模型 ID 解析、定价同步 +- **修复深度审计发现的 9 项问题** — T-001/006/011/014/015/017/019/022/026 +- **修复 MCP client EPIPE 与 mock 兼容** — stdin.on() 空指针保护、登录测试竞态、CI 测试隔离 ~/.markus + +### Improvements + +- **Desktop 通知精简** — 仅显示关键事件通知,减少噪音 +- **Desktop 闪屏改用 Logo** — 启动闪屏使用 Logo 图片替代文字 +- **单测覆盖率 80%+** — 深度代码审计与测试补全 + +### Stats + +- 307 files changed, +52,928 / −3,266 lines + +--- + +## v0.8.3 + +防止通信适配器连接失败导致服务崩溃;侧边栏设置按钮简化;登录提示可见性修复。 + +### Bug Fixes + +- **修复通信适配器崩溃** — comm adapter(飞书/Slack/WhatsApp)连接失败时捕获异常,防止整个服务进程退出 +- **修复侧边栏设置按钮** — 简化设置入口,修复登录提示在特定条件下不可见 + +### Stats + +- 15 files changed, +41 / −37 lines + +--- + +## v0.8.2 + +飞书集成全链路(QR 码注册、消息路由、通知转发);工作流系统(Phase 1);Codex OAuth 支持;暗色主题重设计;聊天输入与侧边栏优化。 + +### New Features + +- **飞书集成** — 一键扫码注册飞书应用、消息路由至 Secretary、通知转发、markus.json 作为凭据唯一来源、私信回退提示 +- **工作流系统 Phase 1** — 工作流创建与运行、自动启动任务、Secretary 审批回退、搜索/mention/实体渲染集成 +- **OpenAI Codex OAuth** — Codex OAuth 代理与 token 刷新、Settings UI 配置 +- **Hub 分享可见性** — Hub 分享状态持久化到 localStorage,新增可见性级别与组织资产标签 + +### Bug Fixes + +- **修复 OAuth 登录竞态** — 解决 OAuth 登录过程中的竞态条件,补充缺失的 i18n key +- **修复 License 同步与引导简化** — License 同步流程优化,Onboarding 简化 +- **修复 Markdown 实体链接** — 正确渲染被反引号包裹的实体链接 +- **修复工作流持久化与重试** — 工作流验证、持久化、重试机制加固 + +### Improvements + +- **暗色主题重设计** — Codex 风格暖中性色调 +- **聊天输入框与侧边栏优化** — 输入框交互改进,侧边栏折叠优化,未读计数 +- **交付物页面聊天面板** — 支持上下文感知的 @mention 和选择工具栏 +- **Work 页面团队分组** — Agent 筛选栏替换为团队分组视图 + +### Stats + +- 101 files changed, +12,620 / −1,484 lines + +--- + +## v0.8.1 + +Windows 安装包修复;企业版试用期调整;UI 小改进。 + +### Bug Fixes + +- **修复 Windows 安装包** — 安装器自动关闭占用进程,chrome:// 按钮回退方案,引导清单移除搜索步骤 +- **调整企业版试用期** — 试用期从 3 天延长至 7 天 + +### Stats + +- 16 files changed, +56 / −45 lines + +--- + +## v0.8.0 + +企业版授权系统(Hub OAuth、组织管理与计费);License 限额看板;工具调用统计统一。 + +### New Features + +- **企业版授权系统** — 基于 Hub OAuth 的企业授权,组织管理与计费 UI,License 激活后自动刷新,Hub 组织成员数展示 +- **License 限额看板** — 统一从 Agent 指标提取工具调用计数,超限横幅提醒 + +### Bug Fixes + +- **修复 Hub 重定向 Auth Header 丢失** — 保留 Hub 重定向时的认证头 +- **修复本地登录路径** — 恢复 Hub 连接后的本地登录入口,版本横幅改为指向网站下载 + +### Stats + +- 48 files changed, +2,802 / −306 lines + +--- + +## v0.7.14 + +完成标记会话内续接修复;注意力重试逻辑改进;聊天滚动 UX 优化。 + +### Bug Fixes + +- **修复完成标记重试** — 完成标记(completion marker)通过会话内续接重试,而非重新创建会话 +- **修复注意力重试逻辑** — attention controller 重试逻辑改进,避免无效重试 +- **修复聊天滚动 UX** — 聊天滚动行为优化 + +### Stats + +- 14 files changed, +144 / −52 lines + +--- + +## v0.7.13 + +修复 Windows 打开 Chrome 扩展页面的启动命令。 + +### Bug Fixes + +- **修复 Windows Chrome 扩展页面** — 修正 Windows 上打开 `chrome://extensions` 的 `start` 命令参数 + +### Stats + +- 12 files changed, +12 / −12 lines (version bump) + +--- + +## v0.7.12 + +修复二进制构建中 node-datachannel 版本不稳定问题。 + +### Bug Fixes + +- **固定 node-datachannel 版本** — 二进制构建中将 node-datachannel 固定为 0.12.0,避免不兼容的版本更新 + +### Stats + +- 12 files changed, +12 / −12 lines (version bump) + +--- + +## v0.7.11 + +修复 Chrome Extension 打包脚本跨平台路径问题。 + +### Bug Fixes + +- **修复跨平台路径解析** — Chrome Extension `pack.mjs` 使用 `fileURLToPath` 替代直接路径操作,修复 Windows 路径兼容性 + +### Stats + +- 12 files changed, +15 / −14 lines + +--- + +## v0.7.10 + +CI 改为直接上传 release 二进制到 R2,不再走 API 中转。 + +### Improvements + +- **R2 直传** — CI 发布流程改为直接上传二进制到 Cloudflare R2,移除 API 中转环节,提升上传可靠性 + +### Stats + +- 12 files changed, +34 / −20 lines + +--- + +## v0.7.9 + +安装脚本添加 markus.global 镜像回退,提升中国区安装体验。 + +### Improvements + +- **安装脚本镜像回退** — `install.sh` 和 `install.ps1` 添加 markus.global 作为下载源回退,GitHub 不可达时自动切换 + +### Stats + +- 14 files changed, +85 / −31 lines + +--- + ## v0.7.8 LiteLLM 模型目录集成(动态模型选择);聊天与工作台 UX 全面优化;Windows 安装包修复;SSE 断连韧性增强;执行时间线 i18n 支持。 diff --git a/docs/RELEASE-AND-DISTRIBUTION.md b/docs/RELEASE-AND-DISTRIBUTION.md index 77c1121b..b138ac3e 100644 --- a/docs/RELEASE-AND-DISTRIBUTION.md +++ b/docs/RELEASE-AND-DISTRIBUTION.md @@ -1,166 +1,61 @@ # Markus 发布与分发指南 -本文档详细说明 Markus 的所有发布产物、打包流程、分发渠道和平台支持情况。 - ## 概览 -Markus 提供三种安装方式,覆盖不同用户场景: +Markus 提供三种安装方式: | 安装方式 | 适用人群 | 自动更新 | 需要 Node.js | |---------|---------|---------|-------------| | **Desktop App** (Electron) | 桌面用户,偏好 GUI | ✅ electron-updater | ❌ 内置 | -| **Binary Installer** (.pkg/.exe/.deb) | 服务器/CLI 用户 | ✅ `markus update` | ❌ 内嵌 Node.js | -| **npm install** | 开发者 | ❌ 手动 | ✅ 需要 Node 22+ | +| **npm install** | 开发者、服务器部署 | ❌ 手动 | ✅ 需要 Node 22+ | +| **Server Binary** (Linux) | Linux 服务器/无头部署 | ❌ 手动 | ❌ 内嵌 Node.js | --- ## 1. Desktop App (Electron) -### 产物 - -| 平台 | 架构 | 格式 | 文件名 | -|------|------|------|--------| -| macOS | arm64 | DMG | `Markus-{VER}-arm64.dmg` | -| macOS | x64 | DMG | `Markus-{VER}.dmg` | -| macOS | arm64 | ZIP | `Markus-{VER}-arm64-mac.zip` | -| macOS | x64 | ZIP | `Markus-{VER}-mac.zip` | -| macOS (MAS) | universal | PKG | 通过 App Store Connect 提交 | -| Windows | x64 | NSIS EXE | `Markus-Setup-{VER}.exe` | -| Linux | x64 | AppImage | `Markus-{VER}.AppImage` | -| Linux | x64 | DEB | `Markus_{VER}_amd64.deb` | +主要面向桌面用户的安装方式。 -### 技术栈 +### 产物 -- **Electron**: 35.x -- **构建工具**: esbuild (打包 main/preload) + electron-builder (打包分发) -- **前端**: 内嵌 Web UI 静态资源 (Vite + React) -- **后端**: 同进程内嵌运行 +| 平台 | 格式 | 文件名 | +|------|------|--------| +| macOS (Apple Silicon) | DMG | `Markus-{VER}-arm64.dmg` | +| macOS (Intel) | DMG | `Markus-{VER}.dmg` | +| Windows x64 | NSIS EXE | `Markus-Setup-{VER}.exe` | +| Linux x64 | AppImage | `Markus-{VER}.AppImage` | ### 签名与公证 -| 平台 | 签名方式 | 公证 | -|------|---------|------| -| macOS (DMG/ZIP) | Developer ID Application | Apple Notarization | -| macOS (MAS) | 3rd Party Mac Developer | App Store Review | +| 平台 | 签名 | 公证 | +|------|------|------| +| macOS | Developer ID Application | Apple Notarization | | Windows | — (计划中) | — | | Linux | — | — | ### 自动更新 -- 使用 `electron-updater`,发布到 GitHub Releases -- MAS 版本禁用自动更新(由 App Store 管理) -- 更新检查频率:应用启动时 +- `electron-updater`,发布到 GitHub Releases +- 更新检查:应用启动时 -### 构建命令 +### 本地开发 ```bash -# 本地开发 cd packages/desktop && pnpm dev -# 构建 + 打包 (目录模式,快速测试) -node packages/desktop/build.mjs -cd packages/desktop && pnpm pack - -# 完整打包 -pnpm dist:mac # macOS DMG + ZIP -pnpm dist:win # Windows NSIS -pnpm dist:linux # Linux AppImage + DEB -pnpm dist:mas # Mac App Store -``` - ---- - -## 2. Binary Installer (CLI) - -独立二进制包,内嵌 Node.js 运行时,用户无需预装任何依赖。 - -### 产物 - -| 平台 | 架构 | 格式 | 文件名 | -|------|------|------|--------| -| macOS | arm64 | PKG | `markus-v{VER}-darwin-arm64.pkg` | -| macOS | x64 | PKG | `markus-v{VER}-darwin-x64.pkg` | -| Windows | x64 | EXE (Inno Setup) | `markus-v{VER}-win-x64-setup.exe` | -| Linux | x64 | DEB | `markus-v{VER}-linux-x64.deb` | - -另有固定文件名版本 (`markus-setup-{platform}-{arch}.{ext}`),供 `/releases/latest/download/` 永久链接使用。 - -### 安装内容 - -安装后的目录结构 (以 macOS 为例): - -``` -/usr/local/lib/markus/ -├── bin/ -│ ├── Markus # Node.js 二进制 (重命名) -│ ├── node -> Markus # 符号链接 -│ ├── markus.mjs # CLI 主入口 (esbuild 单文件打包) -│ ├── tray.mjs # 系统托盘控制器 -│ └── node_modules/ # 原生依赖 (ws, sharp, rfb2, systray2) -├── web-ui/ # 前端静态资源 -├── templates/ # 内置团队/角色/技能模板 -├── chrome-extension/ # 浏览器扩展 zip -├── logo.png -├── markus.icns -├── markus # 启动器脚本 → /usr/local/bin/markus -└── package.json # 版本标记 -``` - -### 安装器行为 - -#### macOS (.pkg) -- **安装位置**: `/usr/local/lib/markus` -- **CLI 入口**: `/usr/local/bin/markus` (符号链接) -- **Markus.app**: 创建到 `/Applications/Markus.app` (托盘启动器) -- **开机自启**: `~/Library/LaunchAgents/global.markus.plist` -- **V8 JIT**: 自动签名 Node.js 二进制以获取 JIT entitlements -- **升级行为**: preinstall 脚本自动停止旧版本再覆盖安装 - -#### Windows (.exe) -- **安装位置**: `%LOCALAPPDATA%\markus` -- **CLI 入口**: 自动添加到 `PATH` -- **桌面快捷方式**: 指向 `markus-tray.vbs`(无控制台窗口启动) -- **开机自启**: 通过开始菜单 Startup 快捷方式 - -#### Linux (.deb) -- **安装位置**: `/usr/local/lib/markus` -- **CLI 入口**: `/usr/local/bin/markus` (符号链接) -- **桌面快捷方式**: `~/Desktop/markus.desktop` -- **开机自启**: `~/.config/autostart/markus.desktop` - -### 便携归档 - -除安装器外,每个平台还生成便携归档: -- macOS/Linux: `.tar.gz` -- Windows: `.zip` - -供 `install.sh` / `install.ps1` 脚本解压到 `~/.markus/app/`。 - -### 构建命令 - -```bash -# 构建某平台的安装器 -bash scripts/build-binary.sh darwin arm64 -bash scripts/build-binary.sh darwin x64 -bash scripts/build-binary.sh win x64 -bash scripts/build-binary.sh linux x64 +# 打包测试 (不签名) +CSC_IDENTITY_AUTO_DISCOVERY=false pnpm exec electron-builder --mac --dir +open dist-electron/mac-arm64/Markus.app ``` --- -## 3. npm 包 - -### 产物 - -| 包名 | 注册表 | 说明 | -|------|--------|------| -| `@markus-global/cli` | npmjs.com | CLI 入口 + 打包后的所有核心逻辑 | +## 2. npm 包 ### 安装 ```bash npm install -g @markus-global/cli -# 或 npm install -g @markus-global/cli@next # 预发布版 ``` @@ -173,66 +68,66 @@ npm install -g @markus-global/cli@next # 预发布版 --- -## 4. Docker (计划中) +## 3. Server Binary (仅 Linux) + +用于 Linux 服务器无头部署,内嵌 Node.js 运行时。macOS / Windows 不再提供独立二进制,请使用 Desktop App 或 npm。 -目前已注释,计划恢复: +### 产物 + +| 格式 | 文件名 | +|------|--------| +| DEB | `markus-setup-linux-x64.deb` | +| tar.gz | `markus-v{VER}-linux-x64.tar.gz` | + +### 一键安装脚本 -```yaml -# markus/markus:{version} -# markus/markus:latest +```bash +curl -fsSL https://markus.global/install.sh | bash ``` +该脚本在 Linux 上自动检测 Node.js:有则用 npm 安装,无则下载 standalone binary。macOS 用户执行此脚本会提示安装 Node.js 或下载 Desktop App。 + --- ## CI/CD 流程 ### 触发条件 -推送 `v*` 格式的 Git tag 触发完整发布流程。 +推送 `v*` 格式的 Git tag。 ### 流水线 ``` -push tag v0.8.3 +push tag v* │ - ├─→ publish-npm 发布 @markus-global/cli 到 npm + ├─→ publish-npm 发布到 npm │ │ - │ ├─→ build-binaries 并行构建 4 平台 CLI 安装器 - │ │ ├── linux-x64 - │ │ ├── darwin-arm64 - │ │ ├── darwin-x64 - │ │ └── win-x64 + │ ├─→ build-server-binary Linux x64 (.deb + .tar.gz) │ │ - │ ├─→ build-desktop 并行构建 4 平台 Electron 桌面版 - │ │ ├── darwin-arm64 - │ │ ├── darwin-x64 - │ │ ├── win-x64 - │ │ └── linux-x64 - │ │ - │ └─→ build-desktop-mas Mac App Store 构建 (仅正式版) + │ └─→ build-desktop 4 平台 Electron 桌面版 + │ ├── macOS arm64 (.dmg) + │ ├── macOS x64 (.dmg) + │ ├── Windows x64 (.exe) + │ └── Linux x64 (.AppImage) │ - ├─→ github-release 创建 GitHub Release + 上传所有产物 + ├─→ github-release 创建 GitHub Release │ - └─→ upload-to-hub 上传二进制到 Cloudflare R2 (仅正式版) + └─→ upload-to-hub 上传到 R2 (仅正式版) ``` +**5 个 CI job,7 个产物。** + ### 所需 Secrets | Secret | 用途 | |--------|------| | `NPM_TOKEN` | npm 发布 | | `GITHUB_TOKEN` | Release 创建、Electron 更新源 | -| `APPLE_CERTIFICATE_P12` | macOS 代码签名 (Developer ID) | +| `APPLE_CERTIFICATE_P12` | macOS 代码签名 | | `APPLE_CERTIFICATE_PASSWORD` | P12 密码 | | `APPLE_ID` | Apple 公证 | -| `APPLE_ID_PASSWORD` | Apple 公证 (App-Specific Password) | +| `APPLE_ID_PASSWORD` | Apple App-Specific Password | | `APPLE_TEAM_ID` | Apple Team ID | -| `MAS_CERTIFICATE_P12` | MAS 签名证书 | -| `MAS_CERTIFICATE_PASSWORD` | MAS P12 密码 | -| `MAS_PROVISIONING_PROFILE` | MAS Provisioning Profile (base64) | -| `ASC_API_KEY` | App Store Connect API Key (base64) | -| `ASC_API_KEY_ID` | ASC Key ID | -| `ASC_API_ISSUER` | ASC Issuer ID | | `R2_ACCESS_KEY_ID` | Cloudflare R2 | | `R2_SECRET_ACCESS_KEY` | Cloudflare R2 | | `R2_ACCOUNT_ID` | Cloudflare R2 | @@ -240,88 +135,29 @@ push tag v0.8.3 --- -## 5. 分发渠道 +## 分发渠道 | 渠道 | 内容 | URL | |------|------|-----| | GitHub Releases | 全部产物 | `github.com/markus-global/markus/releases` | | npm | CLI 包 | `npmjs.com/package/@markus-global/cli` | -| Mac App Store | Desktop App (MAS) | Apple App Store | -| Cloudflare R2 | 二进制安装器 | `markus.global/releases/` | -| install.sh | 一键安装脚本 | `curl -fsSL https://markus.global/install.sh \| bash` | -| install.ps1 | Windows 一键安装 | `irm https://markus.global/install.ps1 \| iex` | - ---- - -## 6. 版本号规则 - -采用 [SemVer](https://semver.org/) 语义化版本: - -``` -{major}.{minor}.{patch}[-{prerelease}] -``` - -- **正式版** (`0.8.3`): 完整 CI 流程,上传到所有渠道 -- **预发布版** (`0.8.4-rc.0`): npm tag 为 `next`,不提交 MAS,不上传到 R2 - -版本号统一在根 `package.json` 管理,各子包通过 `pnpm` workspace 协议引用。 +| Cloudflare R2 | 二进制 + Desktop (CN 加速) | `markus.global/releases/` | +| install.sh | Linux 一键安装脚本 | `curl -fsSL https://markus.global/install.sh \| bash` | --- -## 7. 本地发布测试 - -### 测试桌面版打包 - -```bash -# 1. 构建所有依赖 -pnpm build -pnpm --filter @markus/web-ui build - -# 2. 构建 Electron 主进程 -node packages/desktop/build.mjs - -# 3. 打包为目录 (不签名,快速验证) -cd packages/desktop -CSC_IDENTITY_AUTO_DISCOVERY=false pnpm exec electron-builder --mac --dir - -# 4. 启动测试 -open dist-electron/mac-arm64/Markus.app -``` - -### 测试 CLI 二进制打包 - -```bash -# 构建 CLI bundle -pnpm --filter @markus-global/cli build:bundle - -# 构建本机平台安装器 -bash scripts/build-binary.sh darwin arm64 - -# 安装测试 -sudo installer -pkg dist-binary/markus-v0.8.3-darwin-arm64.pkg -target / -``` - ---- - -## 8. 平台支持矩阵 - -| 平台 | 架构 | Desktop App | CLI Binary | npm | 测试状态 | -|------|------|-------------|-----------|-----|---------| -| macOS | arm64 | ✅ DMG/ZIP | ✅ PKG | ✅ | 🟢 主要开发平台 | -| macOS | x64 | ✅ DMG/ZIP | ✅ PKG | ✅ | 🟡 CI 构建通过 | -| Windows | x64 | ✅ NSIS | ✅ EXE | ✅ | 🟡 CI 构建通过 | -| Linux | x64 | ✅ AppImage/DEB | ✅ DEB | ✅ | 🟡 CI 构建通过 | -| Linux | arm64 | ❌ | ❌ | ✅ | — | -| Windows | arm64 | ❌ | ❌ | ✅ | — | +## 平台支持 -图例:🟢 完整测试 🟡 CI 验证 ❌ 暂不支持 +| 平台 | Desktop App | Server Binary | npm | +|------|-------------|--------------|-----| +| macOS arm64 | ✅ DMG | — | ✅ | +| macOS x64 | ✅ DMG | — | ✅ | +| Windows x64 | ✅ NSIS | — | ✅ | +| Linux x64 | ✅ AppImage | ✅ DEB | ✅ | --- -## 9. 已知问题与限制 +## 已知限制 -1. **Windows/Linux Desktop App 未实际测试** — 仅 CI 构建通过,需要在对应平台验证功能 -2. **Windows 代码签名未实现** — 用户会看到 SmartScreen 警告 -3. **Linux arm64 不支持** — Node.js 官方 arm64 binary 可用,但 native deps 需验证 -4. **MAS 沙箱限制** — 部分功能(shell 执行、本地文件访问)在 MAS 版本中不可用 -5. **Docker 镜像暂停** — 等待 headless 模式完善后恢复 +1. **Windows 代码签名** — 未实现,用户会看到 SmartScreen 警告 +2. **Linux arm64** — 暂不支持 diff --git a/package.json b/package.json index 956f9446..8111e5d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markus", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "description": "AI Native Digital Employee Platform", diff --git a/packages/a2a/package.json b/packages/a2a/package.json index 33c3ea48..e49dad8e 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -1,6 +1,6 @@ { "name": "@markus/a2a", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index 983c0bdc..9dac9a8e 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "@markus/chrome-extension", - "version": "1.0.0", + "version": "0.8.4-rc.9", "private": true, "scripts": { "build": "esbuild src/background.ts --bundle --outfile=dist/background.js --format=esm --target=es2022 --platform=browser", diff --git a/packages/cli/build.mjs b/packages/cli/build.mjs index 73583538..02ed3a37 100644 --- a/packages/cli/build.mjs +++ b/packages/cli/build.mjs @@ -8,7 +8,6 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outfile = resolve(__dirname, 'dist', 'markus.mjs'); -const trayOutfile = resolve(__dirname, 'dist', 'tray.mjs'); // Modules that cannot be bundled: native addons and Node.js built-ins const external = [ @@ -19,10 +18,6 @@ const external = [ 'node-datachannel', ]; -const trayExternal = [ - 'systray2', -]; - async function main() { // Step 1: Compile all workspace packages so TS sources are available console.log(' Building workspace packages...'); @@ -57,29 +52,6 @@ async function main() { resolveExtensions: ['.ts', '.js', '.mjs', '.json'], }); - // Step 2b: Bundle tray controller (standalone entry point for desktop shortcut) - console.log(' Bundling tray controller...'); - await build({ - entryPoints: [resolve(__dirname, 'src/tray.ts')], - outfile: trayOutfile, - bundle: true, - platform: 'node', - target: 'node22', - format: 'esm', - external: trayExternal, - banner: { - js: [ - "import { createRequire } from 'module';", - 'const require = createRequire(import.meta.url);', - ].join('\n'), - }, - sourcemap: false, - minify: false, - treeShaking: true, - conditions: ['node', 'import'], - resolveExtensions: ['.ts', '.js', '.mjs', '.json'], - }); - // Step 3: Copy templates into dist/ so they ship with the npm package const templatesRoot = resolve(__dirname, '../../templates'); const templatesDest = resolve(__dirname, 'templates'); diff --git a/packages/cli/package.json b/packages/cli/package.json index 00badd3b..e058be31 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@markus-global/cli", - "version": "0.8.3", + "version": "0.8.4-rc.9", "description": "Markus — AI Digital Workforce Platform", "type": "module", "license": "AGPL-3.0-or-later", @@ -26,7 +26,6 @@ "clean": "rm -rf dist *.tsbuildinfo templates/" }, "dependencies": { - "systray2": "^2.1.4", "ws": "^8.19.0" }, "optionalDependencies": { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 36c79869..cd21d905 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import type { BackendInstance } from '../backend.js'; -import { resolve, join, dirname } from 'node:path'; +import { resolve, join, dirname, delimiter } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { allTemplateDirs, resolveTemplatesDir, resolveWebUiDir } from '../paths.js'; @@ -516,7 +516,7 @@ async function startServerCore( const cwdBin = join(process.cwd(), 'node_modules', '.bin'); if (existsSync(cwdBin) && !currentPath.includes(cwdBin)) extraPaths.push(cwdBin); if (extraPaths.length > 0) { - process.env['PATH'] = `${extraPaths.join(':')}:${currentPath}`; + process.env['PATH'] = `${extraPaths.join(delimiter)}${delimiter}${currentPath}`; } // Propagate markus.json security settings into env so downstream services can read them @@ -597,10 +597,9 @@ async function startServerCore( const deliverableService = new DeliverableService(storage?.deliverableRepo); await deliverableService.load(); - // One-time migration: sync existing task.deliverables into the unified deliverables table - const allTasks = taskService.listTasks({ orgId: 'default' }); - await deliverableService.migrateFromTasks(allTasks); - await deliverableService.deduplicateByReference(); + // One-time migrations + await taskService.migrateBranchToCompletionSummary(); + await deliverableService.cleanupLegacyRows(); const reportService = new ReportService(taskService, billingService, auditService, knowledgeService); const _trustService = new TrustService(); @@ -1678,7 +1677,7 @@ async function startServerCore( // ── Done — replace console.error spam with progress.finish() ──────────────── const logFile = getStartupLogFile(); - const logFileName = logFile.split('/').pop() ?? logFile; + const logFileName = logFile.replace(/.*[/\\]/, '') || logFile; const uiUrl = `http://localhost:${apiPort}`; progress?.finish(uiUrl); diff --git a/packages/cli/src/tray.ts b/packages/cli/src/tray.ts deleted file mode 100644 index 9b3fea65..00000000 --- a/packages/cli/src/tray.ts +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Cross-platform system tray controller for Markus. - * Launches the server on start, provides "Open Console" and "Quit" controls. - */ - -import { spawn, exec, execSync, type ChildProcess } from 'node:child_process'; -import { readFileSync, existsSync, mkdirSync, appendFileSync, openSync, writeFileSync, unlinkSync } from 'node:fs'; -import { get as httpGet } from 'node:http'; -import { createConnection } from 'node:net'; -import { resolve, dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { platform, homedir } from 'node:os'; -import SysTrayModule from 'systray2'; - -const SysTray = (SysTrayModule as any).default || SysTrayModule; -type SysTrayInstance = InstanceType; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -const WEB_UI_PORT = 8056; -const WEB_UI_URL = `http://localhost:${WEB_UI_PORT}`; - -const BIN_DIR = __dirname; -const APP_DIR = resolve(BIN_DIR, '..'); -const LOG_DIR = resolve(homedir(), '.markus', 'logs'); -const LOG_FILE = join(LOG_DIR, 'tray-stderr.log'); -const MARKUS_DIR = resolve(homedir(), '.markus'); -const LOCK_FILE = join(MARKUS_DIR, 'tray.lock'); - -// ── i18n ────────────────────────────────────────────────────────────────────── - -type Locale = 'en' | 'zh'; - -const STRINGS: Record string; -}> = { - en: { - openUI: 'Open Console', - quit: 'Quit Markus', - tooltip: 'Markus', - portConflictTitle: 'Markus', - portConflictMsg: (port, occupant) => - `Port ${port} is already in use by "${occupant}".\\nMarkus cannot start.\\n\\nFree the port or change it in ~/.markus/markus.json`, - }, - zh: { - openUI: '打开控制台', - quit: '退出 Markus', - tooltip: 'Markus', - portConflictTitle: 'Markus', - portConflictMsg: (port, occupant) => - `端口 ${port} 已被 "${occupant}" 占用。\\nMarkus 无法启动。\\n\\n请释放端口或在 ~/.markus/markus.json 中修改端口`, - }, -}; - -function detectLocale(): Locale { - const lang = (process.env['LANG'] ?? process.env['LC_ALL'] ?? process.env['LANGUAGE'] ?? '').toLowerCase(); - if (lang.startsWith('zh')) return 'zh'; - if (platform() === 'darwin') { - try { - const appleLang = execSync('defaults read -g AppleLanguages 2>/dev/null', { encoding: 'utf-8' }); - if (/zh/.test(appleLang)) return 'zh'; - } catch { /* ignore */ } - } - if (platform() === 'win32') { - try { - const winLang = execSync('powershell -NoProfile -Command "(Get-Culture).Name"', { encoding: 'utf-8' }).trim(); - if (winLang.startsWith('zh')) return 'zh'; - } catch { /* ignore */ } - } - return 'en'; -} - -const t = STRINGS[detectLocale()]; - -// ── Logging ─────────────────────────────────────────────────────────────────── - -function trayLog(msg: string): void { - const ts = new Date().toISOString(); - const line = `${ts} ${msg}\n`; - try { - mkdirSync(LOG_DIR, { recursive: true }); - appendFileSync(LOG_FILE, line); - } catch { /* ignore */ } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function loadIconBase64(): string { - for (const p of [resolve(APP_DIR, 'logo.png'), resolve(APP_DIR, 'markus.ico')]) { - if (existsSync(p)) return readFileSync(p).toString('base64'); - } - return ''; -} - -function openBrowser(url: string): void { - const sys = platform(); - const cmd = sys === 'darwin' ? `open "${url}"` - : sys === 'win32' ? `start "" "${url}"` - : `xdg-open "${url}"`; - exec(cmd, (err) => { - if (err) trayLog(`openBrowser failed: ${err.message}`); - }); -} - -// ── Singleton lock ──────────────────────────────────────────────────────────── - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function acquireLock(): boolean { - try { - mkdirSync(MARKUS_DIR, { recursive: true }); - if (existsSync(LOCK_FILE)) { - const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10); - if (pid && !isNaN(pid) && isProcessAlive(pid)) { - return false; - } - } - writeFileSync(LOCK_FILE, String(process.pid)); - return true; - } catch { return true; } -} - -function releaseLock(): void { - try { - if (existsSync(LOCK_FILE)) { - const pid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim(), 10); - if (pid === process.pid) unlinkSync(LOCK_FILE); - } - } catch { /* ignore */ } -} - -// ── Port / process helpers ──────────────────────────────────────────────────── - -function getPortOccupant(port: number): string { - try { - if (platform() === 'win32') { - const out = execSync( - `netstat -ano | findstr ":${port}" | findstr "LISTENING"`, - { encoding: 'utf-8', timeout: 5000 }, - ).trim(); - const firstLine = out.split('\n')[0]?.trim(); - if (!firstLine) return 'unknown'; - const pid = firstLine.split(/\s+/).pop(); - if (!pid || pid === '0') return 'unknown'; - const taskInfo = execSync( - `tasklist /FI "PID eq ${pid}" /FO CSV /NH`, - { encoding: 'utf-8', timeout: 5000 }, - ).trim(); - return taskInfo.split(',')[0]?.replace(/"/g, '') || 'unknown'; - } else { - return execSync( - `lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null | head -1 | xargs ps -p -o comm= 2>/dev/null`, - { encoding: 'utf-8', timeout: 5000 }, - ).trim() || 'unknown'; - } - } catch { return 'unknown'; } -} - -function getPidsByPort(port: number): number[] { - try { - if (platform() === 'win32') { - const out = execSync( - `netstat -ano | findstr ":${port}" | findstr "LISTENING"`, - { encoding: 'utf-8', timeout: 5000 }, - ).trim(); - if (!out) return []; - const pids = new Set(); - for (const line of out.split('\n')) { - const pid = parseInt(line.trim().split(/\s+/).pop() ?? '', 10); - if (pid && !isNaN(pid) && pid !== 0) pids.add(pid); - } - return [...pids]; - } else { - const out = execSync( - `lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null`, - { encoding: 'utf-8', timeout: 5000 }, - ).trim(); - if (!out) return []; - return out.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => n && !isNaN(n)); - } - } catch { return []; } -} - -function killPid(pid: number): void { - try { - if (platform() === 'win32') { - execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore', timeout: 5000 }); - } else { - process.kill(pid, 'SIGTERM'); - } - } catch { /* ignore */ } -} - -function forceKillPid(pid: number): void { - try { - if (platform() === 'win32') { - execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore', timeout: 5000 }); - } else { - process.kill(pid, 'SIGKILL'); - } - } catch { /* ignore */ } -} - -function showPortConflictDialog(port: number, occupant: string): void { - const msg = t.portConflictMsg(port, occupant); - if (platform() === 'darwin') { - exec(`osascript -e 'display dialog "${msg}" with title "${t.portConflictTitle}" buttons {"OK"} default button "OK" with icon stop'`, () => {}); - } else if (platform() === 'win32') { - const escaped = msg.replace(/'/g, "''"); - exec( - `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms;[System.Windows.Forms.MessageBox]::Show('${escaped}','${t.portConflictTitle}','OK','Error')"`, - () => {}, - ); - } -} - -function isPortListening(port: number): Promise { - return new Promise((res) => { - const socket = createConnection({ port, host: '127.0.0.1' }, () => { socket.destroy(); res(true); }); - socket.on('error', () => { socket.destroy(); res(false); }); - socket.setTimeout(2000, () => { socket.destroy(); res(false); }); - }); -} - -function checkHealthOnce(url: string): Promise { - return new Promise((res) => { - const req = httpGet(url, (r) => { r.resume(); res(r.statusCode !== undefined && r.statusCode >= 200 && r.statusCode < 400); }); - req.on('error', () => res(false)); - req.setTimeout(2000, () => { req.destroy(); res(false); }); - }); -} - -function waitForHealth(url: string, intervalMs = 500, maxMs = 30000): Promise { - return new Promise((ok) => { - const deadline = Date.now() + maxMs; - const check = () => { - const req = httpGet(url, (res) => { - res.resume(); - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 400) { ok(true); return; } - if (Date.now() >= deadline) { ok(false); return; } - setTimeout(check, intervalMs); - }); - req.on('error', () => { if (Date.now() >= deadline) ok(false); else setTimeout(check, intervalMs); }); - req.setTimeout(2000, () => req.destroy()); - }; - check(); - }); -} - -function resolveMarkusCommand(): { cmd: string; args: string[] } { - const symlink = '/usr/local/bin/markus'; - if (existsSync(symlink)) return { cmd: symlink, args: ['start'] }; - const wrapper = resolve(APP_DIR, 'markus'); - if (existsSync(wrapper)) return { cmd: wrapper, args: ['start'] }; - const markusBin = resolve(BIN_DIR, 'Markus'); - const nodeBin = platform() === 'darwin' && existsSync(markusBin) ? markusBin : resolve(BIN_DIR, platform() === 'win32' ? 'node.exe' : 'node'); - const markusMjs = resolve(BIN_DIR, 'markus.mjs'); - return { cmd: nodeBin, args: [markusMjs, 'start'] }; -} - -// ── Server process management ──────────────────────────────────────────────── - -let serverProcess: ChildProcess | null = null; - -async function ensureServerRunning(): Promise { - if (await isPortListening(WEB_UI_PORT)) { - const isMarkus = await checkHealthOnce(`${WEB_UI_URL}/api/health`); - if (isMarkus) { - trayLog(`Server already running on port ${WEB_UI_PORT}`); - openBrowser(WEB_UI_URL); - return; - } - const occupant = getPortOccupant(WEB_UI_PORT); - trayLog(`Port ${WEB_UI_PORT} occupied by "${occupant}"`); - showPortConflictDialog(WEB_UI_PORT, occupant); - return; - } - - mkdirSync(LOG_DIR, { recursive: true }); - const { cmd, args } = resolveMarkusCommand(); - trayLog(`Starting server: ${cmd} ${args.join(' ')}`); - - const outFd = openSync(join(LOG_DIR, 'stdout.log'), 'a'); - const errFd = openSync(join(LOG_DIR, 'stderr.log'), 'a'); - - serverProcess = spawn(cmd, args, { - stdio: ['ignore', outFd, errFd], - detached: false, - env: { ...process.env, NO_BROWSER: '1' }, - windowsHide: true, - }); - - serverProcess.on('exit', (code) => { - if (code && code !== 0) trayLog(`Server exited with code ${code}`); - serverProcess = null; - }); - - serverProcess.on('error', (err) => { - trayLog(`Failed to spawn server: ${err.message}`); - serverProcess = null; - }); - - const healthy = await waitForHealth(`${WEB_UI_URL}/api/health`); - if (healthy) { - trayLog('Server healthy — opening browser'); - openBrowser(WEB_UI_URL); - } else { - trayLog('Server did not become healthy within 30s'); - } -} - -/** Stop all Markus server processes and prevent LaunchAgent from restarting. */ -function killServer(): void { - trayLog('Stopping server...'); - - if (platform() === 'darwin') { - try { - const uid = execSync('id -u', { encoding: 'utf-8' }).trim(); - execSync(`launchctl bootout gui/${uid}/global.markus 2>/dev/null`, { encoding: 'utf-8' }); - trayLog('LaunchAgent unloaded'); - } catch { /* not loaded or already unloaded */ } - } - - if (serverProcess) { - if (platform() === 'win32') { - try { execSync(`taskkill /PID ${serverProcess.pid} /T /F`, { stdio: 'ignore', timeout: 5000 }); } catch { /* ignore */ } - } else { - serverProcess.kill('SIGTERM'); - } - serverProcess = null; - } - - const pids = getPidsByPort(WEB_UI_PORT).filter(p => p !== process.pid); - for (const pid of pids) { - trayLog(`Killing server process ${pid}`); - killPid(pid); - } - - setTimeout(() => { - const remaining = getPidsByPort(WEB_UI_PORT).filter(p => p !== process.pid); - for (const pid of remaining) { - trayLog(`Force killing server process ${pid}`); - forceKillPid(pid); - } - }, 3000); -} - -// ── Tray setup ─────────────────────────────────────────────────────────────── - -let tray: SysTrayInstance | null = null; - -async function main() { - trayLog(`Tray starting (locale=${detectLocale()}, pid=${process.pid})`); - - if (!acquireLock()) { - const serverUp = await checkHealthOnce(`${WEB_UI_URL}/api/health`); - if (serverUp) { - trayLog('Another tray instance is already running — opening browser and exiting'); - openBrowser(WEB_UI_URL); - setTimeout(() => process.exit(0), 1000); - return; - } - trayLog('Stale lock file detected (server not healthy) — taking over'); - try { unlinkSync(LOCK_FILE); } catch { /* ignore */ } - writeFileSync(LOCK_FILE, String(process.pid)); - } - - process.on('exit', releaseLock); - process.on('SIGINT', () => { releaseLock(); process.exit(0); }); - process.on('SIGTERM', () => { releaseLock(); process.exit(0); }); - - let trayOk = false; - try { - tray = new SysTray({ - menu: { - icon: loadIconBase64(), - title: '', - tooltip: t.tooltip, - items: [ - { title: t.openUI, tooltip: t.openUI, enabled: true }, - { title: '', tooltip: '', enabled: true }, - { title: t.quit, tooltip: t.quit, enabled: true }, - ], - }, - copyDir: false, - }); - await tray.ready(); - trayOk = true; - - tray.onClick(async (action: { item: { title: string }; seq_id: number }) => { - const title = action.item.title; - if (title === t.openUI) { - openBrowser(WEB_UI_URL); - } else if (title === t.quit) { - trayLog('Quit requested'); - killServer(); - setTimeout(async () => { - releaseLock(); - if (tray) await tray.kill(false); - process.exit(0); - }, 4000); - } - }); - - tray.onError((err: Error) => { - trayLog(`Tray error: ${err.message}`); - }); - } catch (trayErr) { - trayLog(`Tray icon unavailable (${trayErr}) — running in headless mode`); - tray = null; - } - - await ensureServerRunning(); - - if (!trayOk) { - trayLog('Running without tray icon — server is up, keeping process alive'); - await new Promise(() => {}); - } -} - -main().catch((err) => { - trayLog(`Tray failed to start: ${err}`); - releaseLock(); - process.exit(1); -}); diff --git a/packages/comms/package.json b/packages/comms/package.json index d16d94b6..f8a277a7 100644 --- a/packages/comms/package.json +++ b/packages/comms/package.json @@ -1,6 +1,6 @@ { "name": "@markus/comms", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/core/package.json b/packages/core/package.json index 2a79d5cd..63945440 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@markus/core", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index 29f9ce12..7c646fbc 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -232,7 +232,7 @@ export interface TaskServiceBridge { addSubtask(taskId: string, title: string): { id: string; title: string; status: string }; completeSubtask(taskId: string, subtaskId: string): { id: string; title: string; status: string }; getSubtasks?(taskId: string): Array<{ id: string; title: string; status: string }>; - submitForReview(taskId: string, deliverables: Array<{ type: string; reference: string; summary: string; diffStats?: unknown; testResults?: unknown }>, reviewerId?: string): Promise<{ id: string; status: string }>; + submitForReview(taskId: string, deliverables: Array<{ type: string; reference: string; summary: string; diffStats?: unknown; testResults?: unknown }>, reviewerId?: string, completionSummary?: string): Promise<{ id: string; status: string }>; requestRevision(taskId: string, reason: string, author?: string): Promise<{ id: string; title: string; status: string }>; findDuplicateTasks?(orgId: string): Array<{ group: string; tasks: Array<{ id: string; title: string; status: string; createdAt: string }> }>; cleanupDuplicateTasks?(orgId: string): { cancelledIds: string[]; count: number }; @@ -1247,10 +1247,8 @@ export class AgentManager { if (!task) throw new Error(`Task not found: ${taskId}`); const reviewerId = (task as Record).reviewerId as string | undefined; const _validTypes = new Set(['file', 'directory']); - const deliverables: Array<{ type: string; reference: string; summary: string }> = [{ - type: 'branch', reference: `task/${taskId}`, - summary: `${summary}${knownIssues ? `\n\nKnown issues: ${knownIssues}` : ''}`, - }]; + const completionSummary = `${summary}${knownIssues ? `\n\nKnown issues: ${knownIssues}` : ''}`; + const deliverables: Array<{ type: string; reference: string; summary: string }> = []; if (Array.isArray(inputDeliverables)) { for (const d of inputDeliverables) { if (d?.reference) { @@ -1261,7 +1259,7 @@ export class AgentManager { } } } - return ts.submitForReview(taskId, deliverables, reviewerId); + return ts.submitForReview(taskId, deliverables, reviewerId, completionSummary); }, proposeRequirement: this.requirementService ? async params => { @@ -2041,10 +2039,8 @@ export class AgentManager { if (!task) throw new Error(`Task not found: ${taskId}`); const reviewerId = (task as Record).reviewerId as string | undefined; const _validTypes = new Set(['file', 'directory']); - const deliverables: Array<{ type: string; reference: string; summary: string }> = [{ - type: 'branch', reference: `task/${taskId}`, - summary: `${summary}${knownIssues ? `\n\nKnown issues: ${knownIssues}` : ''}`, - }]; + const completionSummary = `${summary}${knownIssues ? `\n\nKnown issues: ${knownIssues}` : ''}`; + const deliverables: Array<{ type: string; reference: string; summary: string }> = []; if (Array.isArray(inputDeliverables)) { for (const d of inputDeliverables) { if (d?.reference) { @@ -2055,7 +2051,7 @@ export class AgentManager { } } } - return ts.submitForReview(taskId, deliverables, reviewerId); + return ts.submitForReview(taskId, deliverables, reviewerId, completionSummary); }, proposeRequirement: this.requirementService ? async params => { diff --git a/packages/core/src/tools/process-manager.ts b/packages/core/src/tools/process-manager.ts index 4d6a4c33..989fa7ea 100644 --- a/packages/core/src/tools/process-manager.ts +++ b/packages/core/src/tools/process-manager.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { resolve } from 'node:path'; +import { platform } from 'node:os'; import type { AgentToolHandler } from '../agent.js'; interface BackgroundSession { @@ -120,10 +121,16 @@ export function createBackgroundExecTool(workspacePath?: string): AgentToolHandl const id = `bg_${++sessionCounter}_${Date.now()}`; - const child = spawn('sh', ['-c', command], { - cwd: effectiveCwd, - stdio: ['ignore', 'pipe', 'pipe'], - }); + const isWin = platform() === 'win32'; + const child = spawn( + isWin ? process.env['COMSPEC'] || 'cmd.exe' : 'sh', + isWin ? ['/d', '/s', '/c', command] : ['-c', command], + { + cwd: effectiveCwd, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ); const session: BackgroundSession = { id, diff --git a/packages/core/src/tools/shell-session.ts b/packages/core/src/tools/shell-session.ts index fcdcc784..db62eb80 100644 --- a/packages/core/src/tools/shell-session.ts +++ b/packages/core/src/tools/shell-session.ts @@ -354,20 +354,20 @@ export class ShellSessionManager { } private createSession(sessionId: string, agentId: string, cwd?: string): ManagedSession { - const shell = process.env['SHELL'] || '/bin/sh'; - const isBashLike = /\b(bash|zsh)\b/.test(shell); - const args = isBashLike ? ['--norc', '--noprofile', '-i'] : []; + const isWin = process.platform === 'win32'; + const shell = isWin + ? (process.env['COMSPEC'] || 'cmd.exe') + : (process.env['SHELL'] || '/bin/sh'); + const isBashLike = !isWin && /\b(bash|zsh)\b/.test(shell); + const args = isWin ? ['/Q'] : (isBashLike ? ['--norc', '--noprofile', '-i'] : []); const child = spawn(shell, args, { cwd: cwd ?? process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, - PS1: '', - PS2: '', - PROMPT_COMMAND: '', - TERM: 'dumb', - ENV: '', + ...(isWin ? {} : { PS1: '', PS2: '', PROMPT_COMMAND: '', TERM: 'dumb', ENV: '' }), }, + windowsHide: true, }); const session = new ManagedSession(sessionId, agentId, child); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 13ec30d3..b64fe330 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import { resolve, normalize, sep } from 'node:path'; +import { platform } from 'node:os'; import { SHELL_TIMEOUT_DEFAULT_MS, SHELL_TIMEOUT_MAX_MS, sanitizeForLLM, isToolDisabledInMAS, getMASToolBlockedMessage, type PathAccessPolicy } from '@markus/shared'; import type { AgentToolHandler, ToolOutputCallback } from '../agent.js'; import { defaultSecurityGuard, type SecurityGuard } from '../security.js'; @@ -221,14 +222,19 @@ export function createShellTool(security?: SecurityGuard, workspacePath?: string resolve(result); }; - const child = spawn('sh', ['-c', finalCommand], { - cwd: effectiveCwd ?? undefined, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - env: { ...process.env }, - }); - // Don't let the detached child prevent Node from exiting - child.unref(); + const isWin = platform() === 'win32'; + const child = spawn( + isWin ? process.env['COMSPEC'] || 'cmd.exe' : 'sh', + isWin ? ['/d', '/s', '/c', finalCommand] : ['-c', finalCommand], + { + cwd: effectiveCwd ?? undefined, + stdio: ['ignore', 'pipe', 'pipe'], + detached: !isWin, + env: { ...process.env }, + windowsHide: true, + }, + ); + if (!isWin) child.unref(); let stdout = ''; let stderr = ''; @@ -237,10 +243,13 @@ export function createShellTool(security?: SecurityGuard, workspacePath?: string const timeout = setTimeout(() => { killed = true; - // Kill the entire process group (shell + any children it spawned) - try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* already dead */ } + try { + if (isWin) { child.kill(); } else { process.kill(-child.pid!, 'SIGTERM'); } + } catch { /* already dead */ } setTimeout(() => { - try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* already dead */ } + try { + if (isWin) { child.kill('SIGKILL'); } else { process.kill(-child.pid!, 'SIGKILL'); } + } catch { /* already dead */ } // Force-destroy stdio streams so the `close` event fires even when // grandchild processes still hold inherited pipe file descriptors. child.stdout?.destroy(); diff --git a/packages/core/test/agent-manager-extended.test.ts b/packages/core/test/agent-manager-extended.test.ts index 009e8862..a07f5dde 100644 --- a/packages/core/test/agent-manager-extended.test.ts +++ b/packages/core/test/agent-manager-extended.test.ts @@ -147,6 +147,7 @@ describe('AgentManager extended coverage', () => { 'live_task', expect.any(Array), expect.anything(), + expect.anything(), ); await execPromise; }); diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 7da7f3fb..567589dc 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -17,11 +17,10 @@ Electron 桌面客户端,将 Markus AI 数字员工平台封装为原生桌面 | 平台 | 架构 | 输出格式 | 状态 | |------|------|---------|------| -| macOS | arm64 | dmg, zip | ✅ | -| macOS | x64 | dmg, zip | ✅ | -| macOS (Mac App Store) | universal | mas, pkg | ✅ | +| macOS | arm64 | dmg | ✅ | +| macOS | x64 | dmg | ✅ | | Windows | x64 | nsis installer | ✅ | -| Linux | x64 | AppImage, deb | ✅ | +| Linux | x64 | AppImage | ✅ | ## 项目结构 @@ -128,8 +127,7 @@ GitHub Actions 工作流 (`.github/workflows/publish.yml`) 在创建 tag 时自 1. `publish-npm` — 发布 CLI 到 npm 2. `build-desktop` — 并行构建 macOS (arm64/x64)、Windows、Linux -3. `build-desktop-mas` — Mac App Store 构建(仅正式版) -4. `github-release` — 上传所有产物到 GitHub Release +3. `github-release` — 上传所有产物到 GitHub Release ## 关键技术细节 @@ -160,16 +158,11 @@ const path = join(app.getAppPath().replace('app.asar', 'app.asar.unpacked'), 'di ### 窗口标题栏 -使用 macOS 原生 `hiddenInset` 样式,通过 CSS 注入实现: -- 侧边栏顶部 48px padding 避让红绿灯 -- `body::before` 覆盖全窗口顶部 48px 作为拖拽区域 -- 所有交互元素标记 `no-drag` +macOS 使用 `hiddenInset` 样式,通过 CSS 注入实现侧边栏 48px padding 避让红绿灯。Windows/Linux 使用系统默认标题栏。 ### 自动更新 -通过 `electron-updater` 实现,发布到 GitHub Releases: -- 正常构建自动检查更新 -- MAS 版本 (`MARKUS_MAS=true`) 禁用自动更新(由 App Store 管理) +通过 `electron-updater` 实现,发布到 GitHub Releases,应用启动时自动检查更新。 ### 系统通知 diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index e8226eb3..75126ce5 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -20,8 +20,6 @@ mac: target: - target: dmg arch: [arm64, x64] - - target: zip - arch: [arm64, x64] hardenedRuntime: true gatekeeperAssess: false entitlements: build/entitlements.mac.plist @@ -29,17 +27,6 @@ mac: notarize: true icon: build/icon.icns -mas: - hardenedRuntime: false - entitlements: build/entitlements.mas.plist - entitlementsInherit: build/entitlements.mas.inherit.plist - provisioningProfile: build/markus.provisionprofile - notarize: false - category: public.app-category.developer-tools - target: - - target: mas - arch: [universal] - dmg: sign: false contents: @@ -64,11 +51,10 @@ nsis: createStartMenuShortcut: true linux: + executableName: markus target: - target: AppImage arch: [x64] - - target: deb - arch: [x64] category: Development icon: build/icon.png diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2731bb07..e9dea3d6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,9 +1,13 @@ { "name": "@markus/desktop", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "productName": "Markus", - "author": "Markus Global", + "author": { + "name": "Markus Global", + "email": "team@markus.global" + }, + "homepage": "https://www.markus.global", "description": "Markus Desktop App — Electron wrapper for the AI Digital Workforce Platform", "type": "module", "main": "dist/main.js", diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 6b03d31e..831e237c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -6,7 +6,7 @@ import { setupMenu } from './menu.js'; import { setupTray, destroyTray } from './tray.js'; import { setupIpcHandlers } from './ipc-handlers.js'; import { setupAutoUpdater } from './updater.js'; -import { registerProtocol } from './protocol.js'; +import { registerProtocol, handleSecondInstanceArgs } from './protocol.js'; import { startNotificationBridge, stopNotificationBridge } from './notifications.js'; app.setName('Markus'); @@ -20,7 +20,8 @@ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } else { - app.on('second-instance', () => { + app.on('second-instance', (_event, argv) => { + handleSecondInstanceArgs(argv); restoreOrCreateWindow(backendUrl); }); } @@ -127,34 +128,36 @@ app.whenReady().then(async () => { const currentUrl = win.webContents.getURL(); if (currentUrl.startsWith('http://localhost') || currentUrl.startsWith(backendUrl)) { win.webContents.executeJavaScript(`window.__MARKUS_ELECTRON__ = true;`).catch(() => {}); - // Inject CSS for traffic light clearance and drag region (works even if preload fails) - win.webContents.insertCSS(` - html.electron-app aside { - padding-top: 48px !important; - } - html.electron-app aside > :first-child { - -webkit-app-region: drag; - } - html.electron-app body::before { - content: ''; - display: block; - position: fixed; - top: 0; left: 0; right: 0; - height: 48px; - -webkit-app-region: drag; - z-index: 99999; - pointer-events: none; - } - html.electron-app button, - html.electron-app a, - html.electron-app input, - html.electron-app select, - html.electron-app textarea, - html.electron-app [role="button"], - html.electron-app [data-no-drag] { - -webkit-app-region: no-drag; - } - `).catch(() => {}); + if (process.platform === 'darwin') { + // macOS: inject CSS for traffic light clearance and drag region + win.webContents.insertCSS(` + html.electron-app aside { + padding-top: 48px !important; + } + html.electron-app aside > :first-child { + -webkit-app-region: drag; + } + html.electron-app body::before { + content: ''; + display: block; + position: fixed; + top: 0; left: 0; right: 0; + height: 48px; + -webkit-app-region: drag; + z-index: 99999; + pointer-events: none; + } + html.electron-app button, + html.electron-app a, + html.electron-app input, + html.electron-app select, + html.electron-app textarea, + html.electron-app [role="button"], + html.electron-app [data-no-drag] { + -webkit-app-region: no-drag; + } + `).catch(() => {}); + } win.webContents.executeJavaScript(`document.documentElement.classList.add('electron-app');`).catch(() => {}); } }); diff --git a/packages/desktop/src/protocol.ts b/packages/desktop/src/protocol.ts index 5c054b2f..eb0af3bd 100644 --- a/packages/desktop/src/protocol.ts +++ b/packages/desktop/src/protocol.ts @@ -1,4 +1,4 @@ -import { app, shell } from 'electron'; +import { app } from 'electron'; import { restoreOrCreateWindow } from './window.js'; const PROTOCOL = 'markus'; @@ -6,22 +6,37 @@ const PROTOCOL = 'markus'; export function registerProtocol(): void { if (process.defaultApp) { if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [process.argv[1]]); + app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [process.argv[1]!]); } } else { app.setAsDefaultProtocolClient(PROTOCOL); } - // Handle protocol URL on macOS + // macOS: protocol URLs arrive via open-url event app.on('open-url', (event, url) => { event.preventDefault(); handleProtocolUrl(url); }); + + // Windows/Linux: protocol URL on cold start arrives in process.argv + if (process.platform !== 'darwin') { + const protocolUrl = process.argv.find(arg => arg.startsWith(`${PROTOCOL}://`)); + if (protocolUrl) handleProtocolUrl(protocolUrl); + } +} + +/** + * Handle a protocol URL from a second instance launch (Windows/Linux). + * Called from the second-instance handler in main.ts. + */ +export function handleSecondInstanceArgs(argv: string[]): void { + const protocolUrl = argv.find(arg => arg.startsWith(`${PROTOCOL}://`)); + if (protocolUrl) { + handleProtocolUrl(protocolUrl); + } } function handleProtocolUrl(url: string): void { - // markus://invite?token=xxx → open invite setup page - // markus://open?path=/team/t/123 → navigate to path try { const parsed = new URL(url); const backendUrl = 'http://localhost:8056'; diff --git a/packages/desktop/src/tray.ts b/packages/desktop/src/tray.ts index 7716f467..552b6d2e 100644 --- a/packages/desktop/src/tray.ts +++ b/packages/desktop/src/tray.ts @@ -5,7 +5,7 @@ import { restoreOrCreateWindow } from './window.js'; let tray: Tray | null = null; export function setupTray(backendUrl: string): void { - const iconPath = join(app.getAppPath(), 'build', 'icon.png'); + const iconPath = join(app.getAppPath().replace('app.asar', 'app.asar.unpacked'), 'dist', 'icon.png'); let icon: Electron.NativeImage; try { icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }); diff --git a/packages/desktop/src/window.ts b/packages/desktop/src/window.ts index 120816f2..09812c65 100644 --- a/packages/desktop/src/window.ts +++ b/packages/desktop/src/window.ts @@ -62,8 +62,10 @@ export function createMainWindow(): BrowserWindow { minWidth: 800, minHeight: 600, show: true, - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 16, y: 16 }, + ...(process.platform === 'darwin' ? { + titleBarStyle: 'hiddenInset' as const, + trafficLightPosition: { x: 16, y: 16 }, + } : {}), webPreferences: { preload: join(app.getAppPath().replace('app.asar', 'app.asar.unpacked'), 'dist', 'preload.js'), contextIsolation: true, diff --git a/packages/gui/package.json b/packages/gui/package.json index a401cc8e..94e45884 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -1,6 +1,6 @@ { "name": "@markus/gui", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/org-manager/package.json b/packages/org-manager/package.json index 27379ab4..60ed4e0a 100644 --- a/packages/org-manager/package.json +++ b/packages/org-manager/package.json @@ -1,6 +1,6 @@ { "name": "@markus/org-manager", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 18547c8b..ab032b14 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -3493,20 +3493,43 @@ export class APIServer { if (path === '/api/tasks/deliverables' && req.method === 'GET') { const projectId = url.searchParams.get('projectId') ?? undefined; - const all = this.taskService.listTasks({ projectId }); - const items = all - .filter(t => t.deliverables && t.deliverables.length > 0) - .map(t => ({ - taskId: t.id, - taskTitle: t.title, - taskStatus: t.status, - projectId: t.projectId, - requirementId: t.requirementId, - assignedAgentId: t.assignedAgentId, - updatedAt: t.updatedAt, - deliverables: t.deliverables, - })); - this.json(res, 200, { items }); + if (this.deliverableService) { + const { results } = this.deliverableService.search({ projectId, limit: 500 }); + const grouped = new Map(); + for (const d of results) { + if (!d.taskId) continue; + if (!grouped.has(d.taskId)) { + const task = this.taskService.getTask(d.taskId); + grouped.set(d.taskId, { + taskId: d.taskId, + taskTitle: task?.title ?? '', + taskStatus: task?.status ?? '', + projectId: task?.projectId, + requirementId: task?.requirementId, + assignedAgentId: task?.assignedAgentId, + updatedAt: task?.updatedAt, + deliverables: [], + }); + } + grouped.get(d.taskId)!.deliverables.push(d); + } + this.json(res, 200, { items: [...grouped.values()] }); + } else { + const all = this.taskService.listTasks({ projectId }); + const items = all + .filter(t => t.deliverables && t.deliverables.length > 0) + .map(t => ({ + taskId: t.id, + taskTitle: t.title, + taskStatus: t.status, + projectId: t.projectId, + requirementId: t.requirementId, + assignedAgentId: t.assignedAgentId, + updatedAt: t.updatedAt, + deliverables: t.deliverables, + })); + this.json(res, 200, { items }); + } return; } diff --git a/packages/org-manager/src/deliverable-service.ts b/packages/org-manager/src/deliverable-service.ts index 001849e7..820376e4 100644 --- a/packages/org-manager/src/deliverable-service.ts +++ b/packages/org-manager/src/deliverable-service.ts @@ -1,6 +1,6 @@ import { existsSync, cpSync, mkdirSync } from 'node:fs'; import { join, basename } from 'node:path'; -import { createLogger, generateId, type Deliverable, type TaskDeliverable, type Task } from '@markus/shared'; +import { createLogger, generateId, type Deliverable } from '@markus/shared'; import type { DeliverableRepo } from '@markus/storage'; import type { WSBroadcaster } from './ws-server.ts'; const log = createLogger('deliverable-service'); @@ -405,62 +405,23 @@ export class DeliverableService { } /** - * One-time migration: scan existing tasks and create Deliverable entries - * for any task.deliverables that don't yet have a corresponding row. - * Also cleans up any legacy "branch"-type deliverables by marking them outdated. + * Clean up legacy migration markers and branch-type deliverables from the table. + * Safe to call on startup — removes only housekeeping rows. */ - async migrateFromTasks(tasks: Task[]): Promise { - // Clean up legacy branch-type deliverables (branch is task metadata, not a standalone deliverable) - let branchCleaned = 0; + async cleanupLegacyRows(): Promise { + let cleaned = 0; for (const [id, d] of this.cache) { - if ((d.type as string) === 'branch' && d.status !== 'outdated') { - d.status = 'outdated'; - d.updatedAt = new Date().toISOString(); - await this.repo?.update(id, { status: 'outdated' }); - branchCleaned++; - } - } - if (branchCleaned > 0) { - log.info('Cleaned up legacy branch-type deliverables', { count: branchCleaned }); - } - - // Query ALL task IDs that have ever had deliverables (including outdated/deleted) - // so we don't re-import items the user already removed. - const existingTaskIds = this.repo - ? await this.repo.listTaskIdsWithDeliverables() - : new Set([...this.cache.values()].map(d => d.taskId).filter(Boolean) as string[]); - - let migrated = 0; - for (const task of tasks) { - if (!task.deliverables?.length) continue; - if (existingTaskIds.has(task.id)) continue; - - for (const d of task.deliverables) { - if (d.type === 'branch') continue; - try { - await this.create({ - type: this.mapTaskDeliverableType(d.type), - title: d.summary?.slice(0, 200) || d.reference, - summary: d.summary || '', - reference: d.reference, - taskId: task.id, - agentId: task.assignedAgentId, - projectId: task.projectId, - requirementId: task.requirementId, - diffStats: d.diffStats, - testResults: d.testResults, - }); - migrated++; - } catch (err) { - log.warn('Failed to migrate task deliverable', { taskId: task.id, ref: d.reference, error: String(err) }); - } + const isMigrationMarker = d.title === '[migration-processed]' && d.status === 'outdated'; + const isBranchType = (d.type as string) === 'branch'; + if (isMigrationMarker || isBranchType) { + this.cache.delete(id); + await this.repo?.delete(id); + cleaned++; } } - - if (migrated > 0) { - log.info('Migrated task deliverables to unified table', { migrated }); + if (cleaned > 0) { + log.info('Cleaned up legacy deliverable rows', { count: cleaned }); } - return migrated; } private parseTags(raw: unknown): string[] { @@ -475,12 +436,6 @@ export class DeliverableService { return []; } - private mapTaskDeliverableType(type: TaskDeliverable['type']): Deliverable['type'] { - switch (type) { - case 'file': return 'file'; - default: return 'file'; - } - } private rowToDeliverable(r: { id: string; diff --git a/packages/org-manager/src/task-service.ts b/packages/org-manager/src/task-service.ts index 6ffd517b..41e06206 100644 --- a/packages/org-manager/src/task-service.ts +++ b/packages/org-manager/src/task-service.ts @@ -1082,13 +1082,18 @@ export class TaskService { lines.push(`- ${note.slice(0, PROMPT_DEP_NOTE_CHARS)}`); } } + if (depTask.completionSummary) { + lines.push(`**Completion Summary:** ${depTask.completionSummary}`); + } if (depTask.deliverables?.length) { - lines.push('**Deliverables (review these for background context):**'); - for (const d of depTask.deliverables) { - const refInfo = d.type === 'file' ? ` — File: \`${d.reference}\` (use \`file_read\` to inspect)` : - d.type === 'branch' ? ` [branch: ${d.reference}]` : - d.reference ? ` — ref: \`${d.reference}\`` : ''; - lines.push(`- ${d.summary ?? '(no summary)'}${refInfo}`); + const files = depTask.deliverables.filter(d => d.type !== 'branch' && d.reference); + if (files.length > 0) { + lines.push('**Deliverables (review these for background context):**'); + for (const d of files) { + const refInfo = d.type === 'file' ? ` — File: \`${d.reference}\` (use \`file_read\` to inspect)` : + d.reference ? ` — ref: \`${d.reference}\`` : ''; + lines.push(`- ${d.summary ?? '(no summary)'}${refInfo}`); + } } } depSections.push(lines.join('\n')); @@ -1622,6 +1627,7 @@ export class TaskService { blockedBy, result: (row.result as Task['result']) ?? undefined, deliverables: Array.isArray((row as any).deliverables) ? ((row as any).deliverables as Task['deliverables']) : undefined, + completionSummary: (row as any).completionSummary ?? undefined, notes: Array.isArray(row.notes) ? (row.notes as string[]) : undefined, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), @@ -1649,6 +1655,36 @@ export class TaskService { } } + /** + * One-time migration: extract branch deliverable summaries into task.completionSummary + * and remove branch items from task.deliverables JSON. + */ + async migrateBranchToCompletionSummary(): Promise { + let migrated = 0; + for (const [taskId, task] of this.tasks) { + if (task.completionSummary) continue; + if (!task.deliverables?.length) continue; + + const branchItem = task.deliverables.find(d => d.type === 'branch'); + if (!branchItem) continue; + + task.completionSummary = branchItem.summary; + task.deliverables = task.deliverables.filter(d => d.type !== 'branch'); + task.updatedAt = new Date().toISOString(); + + if (this.taskRepo) { + this.taskRepo.updateCompletionSummary(taskId, task.completionSummary) + .catch(err => log.warn('Failed to persist completionSummary migration', { taskId, error: String(err) })); + this.taskRepo.updateDeliverables(taskId, task.deliverables) + .catch(err => log.warn('Failed to persist deliverables cleanup', { taskId, error: String(err) })); + } + migrated++; + } + if (migrated > 0) { + log.info(`Migrated branch->completionSummary for ${migrated} tasks`); + } + } + private static readonly PRIORITY_ORDER: Record = { urgent: 0, high: 1, medium: 2, low: 3, }; @@ -2968,7 +3004,7 @@ export class TaskService { // ─── Governance: Submit for Review ───────────────────────────────────────── - async submitForReview(taskId: string, deliverables: TaskDeliverable[], reviewerId?: string): Promise { + async submitForReview(taskId: string, deliverables: TaskDeliverable[], reviewerId?: string, completionSummary?: string): Promise { const task = this.tasks.get(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); if (task.status !== 'in_progress') { @@ -3022,17 +3058,26 @@ export class TaskService { task.reviewerId = reviewerId; } - // Persist deliverables (and reviewerId if changed) to DB + // Persist completionSummary to the task + if (completionSummary) { + task.completionSummary = completionSummary; + } + + // Persist deliverables (and reviewerId/completionSummary if changed) to DB if (this.taskRepo) { this.taskRepo.updateDeliverables(task.id, task.deliverables) .catch(err => log.warn('Failed to persist deliverables to DB', { taskId: task.id, error: String(err) })); + if (completionSummary) { + this.taskRepo.updateCompletionSummary(task.id, completionSummary) + .catch(err => log.warn('Failed to persist completionSummary to DB', { taskId: task.id, error: String(err) })); + } if (reviewerId) { this.taskRepo.update(task.id, { reviewerId }) .catch(err => log.warn('Failed to persist reviewer change to DB', { taskId: task.id, error: String(err) })); } } - // Persist each task deliverable as a standalone Deliverable entity (skip branch — it's task metadata) + // Persist each task deliverable as a standalone Deliverable entity if (this.deliverableService && deliverables.length > 0) { const builderMode = this.detectBuilderMode(task.assignedAgentId); @@ -3143,8 +3188,13 @@ export class TaskService { parts.push(''); parts.push(`**Description:** ${task.description}`); + if (task.completionSummary) { + parts.push(''); + parts.push(`**Summary:** ${task.completionSummary}`); + } + if (task.deliverables && task.deliverables.length > 0) { - const files = task.deliverables.filter(d => d.type !== 'branch'); + const files = task.deliverables.filter(d => d.type !== 'branch' && d.reference); if (files.length > 0) { parts.push(''); parts.push('**Deliverables:**'); @@ -3153,11 +3203,6 @@ export class TaskService { } if (files.length > REVIEWER_FILE_LIST_MAX) parts.push(` ... and ${files.length - REVIEWER_FILE_LIST_MAX} more`); } - const branch = task.deliverables.find(d => d.type === 'branch'); - if (branch) { - parts.push(`**Branch:** ${branch.reference}`); - if (branch.summary) parts.push(`**Summary:** ${branch.summary}`); - } } // Include subtask status if any @@ -3866,13 +3911,18 @@ export class TaskService { lines.push('**Notes (most recent first):**'); for (const note of depTask.notes.slice(-PROMPT_DEP_NOTES_MAX).reverse()) lines.push(`- ${note.slice(0, PROMPT_DEP_NOTE_CHARS)}`); } + if (depTask.completionSummary) { + lines.push(`**Completion Summary:** ${depTask.completionSummary}`); + } if (depTask.deliverables?.length) { - lines.push('**Deliverables (review these for background context):**'); - for (const d of depTask.deliverables) { - const refInfo = d.type === 'file' ? ` — File: \`${d.reference}\` (use \`file_read\` to inspect)` : - d.type === 'branch' ? ` [branch: ${d.reference}]` : - d.reference ? ` — ref: \`${d.reference}\`` : ''; - lines.push(`- ${d.summary ?? '(no summary)'}${refInfo}`); + const files = depTask.deliverables.filter(d => d.type !== 'branch' && d.reference); + if (files.length > 0) { + lines.push('**Deliverables (review these for background context):**'); + for (const d of files) { + const refInfo = d.type === 'file' ? ` — File: \`${d.reference}\` (use \`file_read\` to inspect)` : + d.reference ? ` — ref: \`${d.reference}\`` : ''; + lines.push(`- ${d.summary ?? '(no summary)'}${refInfo}`); + } } } depSections.push(lines.join('\n')); diff --git a/packages/org-manager/test/api-server-deep.test.ts b/packages/org-manager/test/api-server-deep.test.ts index 207f3f9b..d28f3387 100644 --- a/packages/org-manager/test/api-server-deep.test.ts +++ b/packages/org-manager/test/api-server-deep.test.ts @@ -17,10 +17,10 @@ import { const mockFetch = vi.fn(); -async function waitForResponse(res: MockServerResponse, maxTicks = 50): Promise { - for (let i = 0; i < maxTicks; i++) { - if (res.ended) return; - await new Promise((resolve) => setImmediate(resolve)); +async function waitForResponse(res: MockServerResponse, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (!res.ended && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); } } diff --git a/packages/org-manager/test/deliverable-service.test.ts b/packages/org-manager/test/deliverable-service.test.ts index 3b4a57b1..f54fc266 100644 --- a/packages/org-manager/test/deliverable-service.test.ts +++ b/packages/org-manager/test/deliverable-service.test.ts @@ -11,6 +11,7 @@ function createMockRepo() { }), listAll: vi.fn(async () => [...rows.values()]), recordAccess: vi.fn().mockResolvedValue(undefined), + delete: vi.fn(async (id: string) => { rows.delete(id); }), listTaskIdsWithDeliverables: vi.fn(async () => new Set()), _rows: rows, }; @@ -187,17 +188,6 @@ describe('DeliverableService', () => { expect(service.get('dlv-db')?.title).toBe('Loaded'); }); - it('migrates deliverables from tasks', async () => { - const count = await service.migrateFromTasks([{ - id: 'task-1', - projectId: 'proj-1', - assignedAgentId: 'agent-1', - deliverables: [{ type: 'file', reference: '/out.txt', summary: 'output' }], - } as never]); - expect(count).toBe(1); - expect(service.findByTask('task-1')).toHaveLength(1); - }); - it('finds deliverables by agent and checks file health', async () => { const created = await service.create({ type: 'file', @@ -210,13 +200,5 @@ describe('DeliverableService', () => { expect(service.checkFileHealth('agent-1')).toContain(created.id); }); - it('skips re-migrating tasks that already have deliverables', async () => { - repo.listTaskIdsWithDeliverables.mockResolvedValue(new Set(['task-1'])); - const count = await service.migrateFromTasks([{ - id: 'task-1', - deliverables: [{ type: 'file', reference: '/out.txt', summary: 'output' }], - } as never]); - expect(count).toBe(0); - }); }); }); diff --git a/packages/remote/package.json b/packages/remote/package.json index aa3fb29a..73412dbf 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@markus/remote", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/shared/package.json b/packages/shared/package.json index 7fbd0150..5b7ae3c6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@markus/shared", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/shared/src/types/governance.ts b/packages/shared/src/types/governance.ts index 695db776..e47ab64a 100644 --- a/packages/shared/src/types/governance.ts +++ b/packages/shared/src/types/governance.ts @@ -46,6 +46,7 @@ export interface SystemAnnouncement { // ─── Task Delivery ─────────────────────────────────────────────────────────── export interface TaskDeliverable { + /** @deprecated 'branch' type is no longer produced — kept temporarily for backward compat during migration */ type: 'branch' | DeliverableType; reference: string; summary: string; diff --git a/packages/shared/src/types/task.ts b/packages/shared/src/types/task.ts index 9f03e87d..179593d3 100644 --- a/packages/shared/src/types/task.ts +++ b/packages/shared/src/types/task.ts @@ -122,6 +122,8 @@ export interface Task { /** Whether the reviewer is an agent or a human user (default: 'agent') */ reviewerType?: 'agent' | 'human'; deliverables?: TaskDeliverable[]; + /** Agent's completion summary (set on task_submit_review) */ + completionSummary?: string; // ── Scheduling fields ── /** 'standard' (default) or 'scheduled' for cron/recurring tasks */ diff --git a/packages/storage/package.json b/packages/storage/package.json index 2b90b9fb..9a0d1795 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@markus/storage", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 2339ed04..08a1a670 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -706,6 +706,7 @@ export function openSqlite(dbPath: string): DatabaseSync { { table: 'deliverables', column: 'format', sql: 'ALTER TABLE deliverables ADD COLUMN format TEXT' }, { table: 'task_comments', column: 'reply_to_id', sql: 'ALTER TABLE task_comments ADD COLUMN reply_to_id TEXT' }, { table: 'requirement_comments', column: 'reply_to_id', sql: 'ALTER TABLE requirement_comments ADD COLUMN reply_to_id TEXT' }, + { table: 'tasks', column: 'completion_summary', sql: 'ALTER TABLE tasks ADD COLUMN completion_summary TEXT' }, ]; for (const m of migrations) { const cols = _db.prepare(`PRAGMA table_info(${m.table})`).all() as Array<{ name: string }>; @@ -1163,6 +1164,12 @@ export class SqliteTaskRepo { .run(toJson(deliverables), now(), id); } + async updateCompletionSummary(id: string, summary: string) { + this.db + .prepare('UPDATE tasks SET completion_summary = ?, updated_at = ? WHERE id = ?') + .run(summary, now(), id); + } + listByOrg(orgId: string, filters?: { status?: string; assignedAgentId?: string; projectId?: string; taskType?: string }) { let q = 'SELECT * FROM tasks WHERE org_id = ?'; const vals: SqlParams = [orgId]; @@ -1291,6 +1298,7 @@ export class SqliteTaskRepo { completedAt: toDate(r['completed_at'] as string), taskType: (r['task_type'] as string) ?? 'standard', scheduleConfig: fromJson(r['schedule_config'] as string), + completionSummary: (r['completion_summary'] as string) ?? undefined, createdAt: toDate(r['created_at'] as string), updatedAt: toDate(r['updated_at'] as string), dueAt: toDate(r['due_at'] as string), @@ -3525,6 +3533,10 @@ export class SqliteDeliverableRepo { this.db.prepare("UPDATE deliverables SET status = 'outdated', updated_at = ? WHERE id = ?").run(now(), id); } + async delete(id: string) { + this.db.prepare('DELETE FROM deliverables WHERE id = ?').run(id); + } + async listAll(limit = 500) { const rows = this.db.prepare("SELECT * FROM deliverables WHERE status != 'outdated' ORDER BY updated_at DESC LIMIT ?").all(limit) as Record[]; return rows.map(r => this.mapRow(r)); diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index a5075aa4..22406e32 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -332,6 +332,7 @@ export interface TaskRepo { assign(id: string, agentId: string, updatedBy?: string): Promise; update(id: string, data: Record): Promise; updateDeliverables(id: string, deliverables: unknown): Promise; + updateCompletionSummary(id: string, summary: string): Promise; } /** Contract for task log persistence */ @@ -358,6 +359,7 @@ export interface DeliverableRepo { create(data: Record): Promise; update(id: string, data: Record): Promise; recordAccess(id: string): Promise; + delete(id: string): Promise; listAll(limit?: number): Promise; listTaskIdsWithDeliverables(): Promise>; } diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 79a7efb3..75a7522e 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@markus/web-ui", - "version": "0.8.3", + "version": "0.8.4-rc.9", "private": true, "type": "module", "exports": { diff --git a/packages/web-ui/src/pages/Deliverables.tsx b/packages/web-ui/src/pages/Deliverables.tsx index 4f5613fd..9c54fe59 100644 --- a/packages/web-ui/src/pages/Deliverables.tsx +++ b/packages/web-ui/src/pages/Deliverables.tsx @@ -765,8 +765,8 @@ export function DeliverablesPage({ authUser: _authUser, previewMode, previewData {/* Metadata */}
- {t('metadata.created')} {new Date(selected.createdAt).toLocaleDateString()} - {t('metadata.updated')} {new Date(selected.updatedAt).toLocaleDateString()} + {t('metadata.created')} {new Date(selected.createdAt).toLocaleString()} + {t('metadata.updated')} {new Date(selected.updatedAt).toLocaleString()} {selected.id.slice(0, 12)}
diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index 230612bd..5906c686 100644 --- a/packages/web-ui/src/pages/Work.tsx +++ b/packages/web-ui/src/pages/Work.tsx @@ -1708,6 +1708,14 @@ function TaskDetailPanel({ const [showRevision, setShowRevision] = useState(false); const [revisionReason, setRevisionReason] = useState(''); const [deliverablesPage, setDeliverablesPage] = useState(1); + const [unifiedDeliverables, setUnifiedDeliverables] = useState>([]); + const loadUnifiedDeliverables = useCallback(async () => { + try { + const { results } = await api.deliverables.search({ taskId: task.id, limit: 200 }); + setUnifiedDeliverables(results.filter((d: any) => d.status !== 'outdated')); + } catch { /* ok */ } + }, [task.id]); + useEffect(() => { void loadUnifiedDeliverables(); }, [loadUnifiedDeliverables]); const [descExpanded, setDescExpanded] = useState(false); const isMobile = useIsMobile(); const PAGE_SIZE = 20; @@ -1858,7 +1866,7 @@ function TaskDetailPanel({ {isRunning && }