diff --git a/.env.example b/.env.example index 1b5b762..e674317 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,10 @@ OLLAMA_MODEL=qwen3:32b # # To reach MAESTRO from another machine on a BARE-METAL install, set HOST # explicitly (e.g. 0.0.0.0) -- and enable auth in config.yaml first. -# (The Docker image already sets HOST=0.0.0.0 inside the container; external -# access is gated by the 127.0.0.1:9876 port mapping in docker-compose.yml.) +# (Docker is different: the image sets HOST=0.0.0.0 inside the container AND +# docker-compose.yml publishes the port on all host interfaces by default, so a +# Docker deployment is LAN-reachable out of the box. Enable auth/TLS before +# leaving it exposed; pin the compose mapping to 127.0.0.1:9876:9876 to undo.) # HOST=0.0.0.0 # HTTP(S) listen port (default 9876). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index db18f92..e0e59be 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: Security vulnerability - url: https://github.com/your-org/maestro/blob/main/SECURITY.md + url: https://gitea.swallow.wjg.jp/swallow/maestro/src/branch/main/SECURITY.md about: Do not open a public issue for a vulnerability. Follow the security policy instead. - name: Documentation - url: https://github.com/your-org/maestro/tree/main/docs + url: https://gitea.swallow.wjg.jp/swallow/maestro/src/branch/main/docs about: Setup, configuration, and architecture guides. diff --git a/.gitignore b/.gitignore index 73fc08f..d54f0d3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,14 @@ data/ .env.* !.env.example config.yaml +# Real config variants / backups at repo root (config-dogfood.yaml, +# config.yaml.bak-, ...) hold live credentials — never track them. +# Root-anchored: an unanchored `config*.yaml` would also swallow the tracked +# ui/e2e-auth/config.e2e-auth.yaml test fixture. +/config*.yaml +!/config.yaml.example +*.bak-* +feedback/ .superpowers/ *.db *.db-wal diff --git a/Dockerfile b/Dockerfile index 51f5d76..fefa8d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -145,10 +145,11 @@ RUN mkdir -p /app/data /workspaces \ && chown -R node:node /app/data /workspaces config.yaml # HOST=0.0.0.0 is required INSIDE the container so the published port is -# reachable. This is safe because docker-compose maps it to 127.0.0.1:9876 on -# the host — the container is not exposed to the LAN unless the operator changes -# that mapping. The app's own default (when HOST is unset, e.g. bare-metal) is -# 127.0.0.1; see createCoreServer in src/bridge/server.ts. +# reachable. docker-compose.yml publishes that port on ALL host interfaces by +# default, so a Docker deployment is reachable from the LAN out of the box — +# enabling auth/TLS before exposing it is the operator's responsibility (see +# docs/docker.md, SECURITY.md). The app's own default (when HOST is unset, e.g. +# bare-metal) is 127.0.0.1; see createCoreServer in src/bridge/server.ts. ENV NODE_ENV=production \ PORT=9876 \ HOST=0.0.0.0 \ diff --git a/README.ja.md b/README.ja.md index cbee965..1227862 100644 --- a/README.ja.md +++ b/README.ja.md @@ -14,16 +14,20 @@ OpenAI 互換の LLM エンドポイント([Ollama](https://ollama.com/) / vLL ## スクリーンショット -3ペイン構成のワークスペース。左にタスク一覧、中央にライブの会話とレンダリング済み成果物、右に状態・ミッションブリーフ・ファイル・トレースをまとめたサイドパネル: +ワークスペース画面。左端にワークスペース一覧、その隣に選択中ワークスペースのチャット一覧、残りの領域にエージェントとのライブ会話とレンダリング済み成果物が並ぶ。概要・進捗・ファイル・トレースはタブで切り替える: -![タスクワークスペース](docs/screenshots/workspace.png) +![ワークスペース](docs/screenshots/workspace.png)
-ほかのスクリーンショット — タスク一覧・設定 +ほかのスクリーンショット — ワークスペース・チャット一覧・設定 -実行中 / 待機中 / 完了をひと目で把握できるタスク一覧(クイックフィルタ付き): +個人ワークスペースと案件ごとのワークスペースが並ぶ。同じエージェントがどこでも動く: -![タスク一覧](docs/screenshots/task-list.png) +![ワークスペース一覧](docs/screenshots/spaces.png) + +実行中 / 待機中 / 完了をひと目で把握できるチャット一覧(クイックフィルタ付き): + +![チャット一覧](docs/screenshots/task-list.png) 設定画面。`config.yaml` の各セクションがフォーム化(LLM ワーカー・サンドボックス・認証・ツールほか): @@ -54,7 +58,7 @@ docker compose up -d # 初回はビルドしてから起動 `.env` や `config.yaml` は用意しなくても起動する。未設定のまま起動すると UI にセットアップウィザードが開き、そこから LLM の接続先を設定できる。先に指定しておきたい場合は `cp .env.example .env` して `OLLAMA_BASE_URL` / `OLLAMA_MODEL` を設定する。 -Compose は安全のため `127.0.0.1:9876` のみに公開する。別ホストからアクセス可能にする前に OAuth 認証を設定し、TLS 対応のリバースプロキシを配置すること。LLM エンドポイントは `.env` / `config.yaml` で指定する。 +Compose は `9876` を全インターフェースに公開するので、Docker ホストは初期状態から LAN で到達でき、設定するまで認証なし。共有ネットワークに置いたままにする前に、認証・`safety.bash_sandbox: always`・TLS(ネイティブ `server.tls` かリバースプロキシ)を有効化すること。ローカル限定に戻すにはマッピングを `127.0.0.1:9876:9876` に固定する。LLM エンドポイントは `.env` / `config.yaml` で指定する。 Docker の詳細ガイド(Linux のネットワーク・データ永続化・サンドボックス・トラブルシューティング)は **[docs/docker.md](docs/docker.ja.md)**。 diff --git a/README.md b/README.md index 13fe312..58b9468 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,20 @@ It works standalone as long as you have an OpenAI-compatible LLM endpoint ([Olla ## Screenshots -The three-pane workspace — task list on the left, the live conversation and rendered output in the center, and a structured side panel (status, mission brief, files, trace) on the right: +The workspace — a rail of your workspaces on the left, the chats inside the selected one next to it, and the live conversation with the agent's rendered output filling the rest. Overview, progress, files, and the full trace are one tab away: -![Task workspace](docs/screenshots/workspace.png) +![Workspace](docs/screenshots/workspace.png)
-More screenshots — task list and settings +More screenshots — workspaces, chat list, and settings -Task list with at-a-glance status (running / waiting / done) and quick filters: +Your personal workspace and each project workspace sit side by side; the same agent runs in every one: -![Task list](docs/screenshots/task-list.png) +![Workspaces](docs/screenshots/spaces.png) + +Chats with at-a-glance status (running / waiting / done) and quick filters: + +![Chat list](docs/screenshots/task-list.png) Settings: every `config.yaml` section as a form — LLM workers, sandbox, auth, tools, and more: @@ -54,7 +58,7 @@ docker compose up -d # builds on first run, then starts No `.env` or `config.yaml` is needed to start: a fresh `docker compose up` opens a setup wizard in the UI where you point MAESTRO at your LLM. To preset it instead, `cp .env.example .env` and set `OLLAMA_BASE_URL` / `OLLAMA_MODEL`. -For safety, Compose exposes only `127.0.0.1:9876`. Before making it reachable from another host, configure OAuth authentication and place a TLS-enabled reverse proxy in front. Specify the LLM endpoint in `.env` / `config.yaml`. +Compose publishes `9876` on all interfaces, so a Docker host is reachable from your LAN out of the box — and unauthenticated until you configure it. Before leaving it on a shared network, enable authentication, `safety.bash_sandbox: always`, and TLS (native `server.tls` or a reverse proxy); pin the mapping to `127.0.0.1:9876:9876` to keep it local-only. Specify the LLM endpoint in `.env` / `config.yaml`. Full Docker guide (Linux networking, data persistence, the sandbox, troubleshooting): **[docs/docker.md](docs/docker.md)**. diff --git a/SECURITY.md b/SECURITY.md index 5054e1d..86ff917 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,11 +6,14 @@ Security fixes are applied to the latest release and the `main` branch. ## Reporting a Vulnerability -Do not open a public issue for an undisclosed vulnerability. Use the -repository host's private security-reporting feature when available, or contact -the repository owner privately. Include affected versions, impact, reproduction -steps, and any suggested mitigation. Maintainers should acknowledge a report -within seven days and coordinate disclosure after a fix is available. +Do not open a public issue for an undisclosed vulnerability. The repository is +hosted on Gitea, which has no private vulnerability-reporting feature, so +contact the repository owner privately through the hosting platform instead of +filing a public issue. + +Include affected versions, impact, reproduction steps, and any suggested +mitigation. Maintainers should acknowledge a report within seven days and +coordinate disclosure after a fix is available. ## Deployment Baseline diff --git a/config.yaml.example b/config.yaml.example index c88bc7b..8cf7f29 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -462,7 +462,7 @@ tools: # notifications: # push: # enabled: false # true で V2 を有効化 -# vapid_subject: "https://maestro.example.com/" # RFC 8292 — operations URL preferred +# vapid_subject: "https://maestro.example.com/" # RFC 8292 — operations URL preferred (運用サイトの URL か mailto:) # vapid_current_path: "./data/secrets/vapid.json" # 自動生成 (mode 0600) # vapid_history_dir: "./data/secrets/vapid-history" # payload_max_bytes: 3072 # JSON byte length cap (上限 4096) @@ -479,3 +479,10 @@ tools: # enabled: false # issuer: "https://localhost:8080/oidc" # 認可サーバーの issuer(公開 URL) # resource_audience: "https://localhost:8080/a2a" # トークン audience(A2A endpoint) +# limits: +# rate_per_minute: 30 # ingress requests/min per delegation (token bucket) +# max_concurrent_tasks: 5 # simultaneous in-flight A2A tasks per delegation +# max_payload_bytes: 65536 # HTTP body + message size cap +# max_stream_seconds: 600 # max SSE stream duration (task + resubscribe) +# skill_budget_per_hour: 100 # accepted tasks per rolling hour per delegation +# max_concurrent_resubscribe: 5 # simultaneous tasks/resubscribe streams per delegation diff --git a/docker-compose.yml b/docker-compose.yml index a4163fe..def9712 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,15 @@ services: # pool work. Harmless when display_mode is headless. shm_size: "1gb" ports: - # Auth is optional, so keep the default deployment local-only. - - "127.0.0.1:9876:9876" + # Published on ALL host interfaces so a Docker host is reachable from your + # LAN out of the box (the common case for a headless server). This means a + # fresh instance is exposed: the agent API includes the Bash tool, so an + # UNAUTHENTICATED instance on the network is effectively remote code + # execution. Before leaving it up on a shared/untrusted network, enable + # auth (`auth` in config.yaml), `safety.bash_sandbox: always`, and TLS — + # see docs/docker.md "Going beyond localhost". To keep it local-only + # instead, change this to "127.0.0.1:9876:9876". + - "9876:9876" extra_hosts: - "host.docker.internal:host-gateway" # .env is optional: a bare `docker compose up --build` works with zero setup. diff --git a/docs/docker.ja.md b/docs/docker.ja.md index dc7eea3..2a4dd96 100644 --- a/docs/docker.ja.md +++ b/docs/docker.ja.md @@ -15,7 +15,7 @@ docker compose up -d # 初回はイメージをビルドしてから起動 `.env` や `config.yaml` は用意しなくても起動します。未設定のまま起動すると UI にセットアップウィザードが開き、そこから LLM の接続先を設定できます。先に指定しておきたい場合は `cp .env.example .env` して `OLLAMA_BASE_URL` / `OLLAMA_MODEL` を設定します([LLM エンドポイント](#llm-エンドポイント)参照)。 -Compose は UI を `127.0.0.1:9876` だけに公開するので、初期状態では LAN から到達できません。外部公開は[ローカル以外へ出す](#ローカル以外へ出す)を参照。 +Compose は `9876` を**全インターフェース**に公開するので、Docker ホストは初期状態から LAN で到達できます。裏を返すと初期状態は**認証なしで露出**しており、ポートに届く相手はエージェントの Bash ツール経由でホスト上のコードを実行できます。共有ネットワークに置いたままにする前に[ローカル以外へ出す](#ローカル以外へ出す)を必ず読んでください。 ### Windows(WSL2) @@ -94,12 +94,13 @@ bubblewrap は非特権ユーザー名前空間を必要とします。多くの ## ローカル以外へ出す -既定のバインドは意図的にローカル限定です。ネットワークへ公開する前に: +既定で Compose は `9876` を全インターフェースに公開するため、インスタンスは初期状態から LAN で到達でき、**かつ認証なし**です。共有ネットワーク・信頼できないネットワークで常用する前に必ず堅牢化してください: 1. 認証を有効化(OAuth、またはローカルアカウント)。 2. `safety.bash_sandbox: always` を設定。 3. TLS を終端(MAESTRO のネイティブ HTTPS か、前段のリバースプロキシ)。 -4. compose のポートマッピングを `127.0.0.1:9876:9876` から、公開したいインターフェースへ変更。 + +逆に**ローカル限定**に戻したい場合(SSH トンネル経由で使う等)は、compose のポートマッピングを `127.0.0.1:9876:9876` に固定します。 完全な堅牢化チェックリストは [SECURITY.md](../SECURITY.md) と [getting-started.md](getting-started.md) を参照。 diff --git a/docs/docker.md b/docs/docker.md index 11fcf7a..0da06c8 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -20,8 +20,11 @@ opens a setup wizard in the UI where you point MAESTRO at your LLM. To preset th endpoint instead, `cp .env.example .env` and set `OLLAMA_BASE_URL`/`OLLAMA_MODEL` (see [The LLM endpoint](#the-llm-endpoint)). -Compose publishes the UI on `127.0.0.1:9876` only, so a fresh instance is not -reachable from your LAN. See [Going beyond localhost](#going-beyond-localhost). +Compose publishes the UI on `9876` on **all host interfaces**, so a Docker host +is reachable from your LAN out of the box. That also means a fresh instance is +exposed and **unauthenticated** — anyone who can reach the port can run code on +the host via the agent's Bash tool. Read [Going beyond localhost](#going-beyond-localhost) +before leaving it up on a shared network. ### Windows (WSL2) @@ -129,14 +132,16 @@ Rebuild after pulling new code with `docker compose up -d --build`. ## Going beyond localhost -The default binding is intentionally local-only. Before exposing MAESTRO to a -network: +By default Compose publishes `9876` on all interfaces, so the instance is already +reachable from your LAN — **and unauthenticated**. Before you rely on it on any +shared or untrusted network, harden it: 1. Enable authentication (OAuth, or local accounts). 2. Set `safety.bash_sandbox: always`. 3. Terminate TLS — either MAESTRO's native HTTPS or a reverse proxy in front. -4. Change the compose port mapping from `127.0.0.1:9876:9876` to the interface - you intend to serve. + +To keep an instance **local-only** instead (e.g. reach it over an SSH tunnel), +pin the compose port mapping back to `127.0.0.1:9876:9876`. See [SECURITY.md](../SECURITY.md) and [getting-started.md](getting-started.md) for the full hardening checklist. diff --git a/docs/getting-started.ja.md b/docs/getting-started.ja.md index 575b649..9b5c6db 100644 --- a/docs/getting-started.ja.md +++ b/docs/getting-started.ja.md @@ -101,7 +101,7 @@ docker compose up -d # http://localhost:9876 ``` -DB とワークスペースは named volume(`maestro-data` / `maestro-workspaces`)に永続化される。Compose は既定で `127.0.0.1:9876` のみに公開する。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。 +DB とワークスペースは named volume(`maestro-data` / `maestro-workspaces`)に永続化される。Compose は既定で `9876` を全インターフェースに公開するため、インスタンスは LAN から到達でき、`auth` を設定するまで認証なしのまま。共有ネットワークに置く前に認証・TLS を有効化するか、ローカル限定にしたい場合はマッピングを `127.0.0.1:9876:9876` に固定する。`config.yaml` をホストからマウントする場合は `docker-compose.yml` のコメントを参照。 ### ブラウザ・セットアップウィザード(`config.yaml` を編集しない) diff --git a/docs/getting-started.md b/docs/getting-started.md index f3432f2..3d3f854 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -101,7 +101,7 @@ docker compose up -d # http://localhost:9876 ``` -The DB and workspaces are persisted in named volumes (`maestro-data` / `maestro-workspaces`). By default Compose exposes only `127.0.0.1:9876`. If you want to mount `config.yaml` from the host, see the comments in `docker-compose.yml`. +The DB and workspaces are persisted in named volumes (`maestro-data` / `maestro-workspaces`). By default Compose publishes `9876` on all interfaces, so the instance is reachable from your LAN — and unauthenticated until you configure `auth`. Enable auth/TLS before leaving it on a shared network, or pin the mapping to `127.0.0.1:9876:9876` for local-only. If you want to mount `config.yaml` from the host, see the comments in `docker-compose.yml`. ### Browser setup wizard (no `config.yaml` editing) diff --git a/docs/maintenance-checklist.md b/docs/maintenance-checklist.md index bbdb36a..bdc6d6d 100644 --- a/docs/maintenance-checklist.md +++ b/docs/maintenance-checklist.md @@ -620,6 +620,41 @@ grep -n "a2a_delegations\|a2a_skills" src/db/repository.ts --- +## 16. OSS リリース(`scripts/oss-sync.sh --push`)を実行するとき + +- **スクリーンショットの目視確認(必須)**: `docs/screenshots/*.png` はバイナリのため、release gate(`oss/forbidden.txt` の正規表現スキャン)では**描画内容を検査できない**。スクリーンショットを更新した場合は、push 前に各画像を開いて以下が写り込んでいないか目視で確認する + - 内部ホスト名・URL(Gitea / 社内サービスのアドレスバー等) + - 実在のタスクタイトル・会話内容・ファイル名 + - ユーザー名・メールアドレス・組織名 + - 資格情報やトークンの断片(設定画面のマスク漏れ) +- 撮り直しの手順は README スクリーンショット刷新時のレシピ(隔離 worktree + シードデータ)に従い、実データ環境のキャプチャをそのまま使わない + +--- + +## 17. 承認パーク型 META ツール(RequestTool / RequestPackage)を追加・変更する場合 + +エージェントが「これが欲しい」と申告 → 人が承認 → ジョブ再開、という停車型ツールの配線点。RequestPackage(PR3)は RequestTool を全面ミラーしている。片方だけ直すと不整合になるので、両方を並べて確認すること。 + +**対象ファイル(RequestPackage を例に):** +- `src/engine/tools/package-request.ts` — ツール本体(`TOOL_DEFS` + `executeTool`)。バリデーションは `engine/python-packages.ts` の `parseAndValidateSpec` / `assertNotShadowing` を再利用 +- `src/engine/tools/core.ts` — `ToolContext` に park フラグ(`pendingPackageApproval`)+ recorder(`recordPackageRequest`)+ 補助状態(`installedPackages` / `declinedPackages`) +- `src/engine/tools/index.ts` — module loader / `Object.assign(allDefs, ...)` / **`META_TOOLS` 配列(2 箇所)** / dispatch 分岐 +- `src/engine/tools/tool-categories.ts` — `META_TOOLS` Set にツール名を追加 +- `src/engine/agent-loop/pending-states.ts` — `buildPackageApprovalWaitResult`(park → `next:'WAITING_HUMAN_PACKAGE_REQUEST'`) +- `src/engine/agent-loop.ts` — import + `buildToolApprovalWaitResult` の隣で呼ぶ +- `src/engine/piece-runner.ts` — `WAITING_HUMAN_PACKAGE_REQUEST` sentinel 分岐(waiting_human park)+ **2 つの PieceRunOptions interface** に recorder/補助状態を追加 + ctx 構築で piece/movement を注入 +- `src/worker.ts` — recorder を task/job/space にバインド(feature enabled かつ space 解決可のときのみ)+ waiting_human 分岐で **`wait_reason='package_request'` を永続化**(忘れると resume がマッチせず永久停止) +- **DB(三重ミラー)**: `src/db/schema.sql` / `src/db/migrate.ts` / `src/db/repositories/schema.ts`(`package_requests` テーブル)+ `src/db/repositories/tool-requests.ts`(record/get/list/decide/revert/listDeclined + 型 + rowMapper)+ `src/db/repositories/jobs.ts`(`resumePackageRequestJob` + `createJobIfNoPending` の承認ポーズ判定)。実装は `repositories/*` に置き、`src/db/repository.ts` ファサードに委譲メソッドを足す(`repository.ts` は肥大分割済みの薄いファサード) +- `src/bridge/local-tasks-package-requests-api.ts` — GET 一覧(view 権限)+ POST decide(**task write 権限= owner/admin/space editor のみ**、エージェント自己承認不可)。承認時のインストールは `space-python-packages-service.ts` の共有パスを通す +- `src/bridge/local-tasks-api.ts` — ルート登録 + `LocalTasksApiOptions` に config アクセサ / `server.ts` で配線 +- `ui/src/components/chat/PackageRequestApproval.tsx` + `ChatPane.tsx` + `ui/src/api/tasks.ts` + i18n `chat.json`(ja/en) +- `docs/tools/requestpackage.md`(ReadToolDoc)+ ヘルプ + changelog + +**なぜ必要か:** +停車系は「ジョブ停車=ワーカー解放」で成り立つ(ツール内同期ブロックはデッドロック)。sentinel(`WAITING_HUMAN_*`)は tool → pending-states → agent-loop → piece-runner → **worker(wait_reason 永続化)** の全経路が揃って初めて機能する。承認 API は必ず write 権限ゲートを通し、インストールは共有サービス(bwrap 必須 fail-closed / wheels のみ / 固定 index / shadowing 拒否)経由に限定する。 + +--- + ## 自動検知の可能性 - **ツールモジュール登録漏れ**: `index.ts` と `tools-api.ts` のモジュール一覧を比較するスクリプトで CI チェック可能 diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index cd7c25d..84d4f6c 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/spaces.png b/docs/screenshots/spaces.png new file mode 100644 index 0000000..f1d0327 Binary files /dev/null and b/docs/screenshots/spaces.png differ diff --git a/docs/screenshots/task-list.png b/docs/screenshots/task-list.png index b20ba2a..f96c20b 100644 Binary files a/docs/screenshots/task-list.png and b/docs/screenshots/task-list.png differ diff --git a/docs/screenshots/workspace.png b/docs/screenshots/workspace.png index 79be7ad..7c92b95 100644 Binary files a/docs/screenshots/workspace.png and b/docs/screenshots/workspace.png differ diff --git a/docs/security-hardening.ja.md b/docs/security-hardening.ja.md index 9511248..eab879f 100644 --- a/docs/security-hardening.ja.md +++ b/docs/security-hardening.ja.md @@ -8,12 +8,12 @@ MAESTRO は LLM 駆動のタスクを実行し、その中でコード実行・W ## 脅威モデル(一段落) -UI/API に到達できる者はタスクを作成でき、タスクはツール(Bash・Web・ブラウザ・ファイル、そして有効なら SSH/MCP)を実行できます。認証が無ければ、ポートに到達できる者は誰でもホスト上でコードを実行できることになります。だから既定のデプロイは**ローカル限定・認証なし**です。以下はそれを安全に広げる手順です。 +UI/API に到達できる者はタスクを作成でき、タスクはツール(Bash・Web・ブラウザ・ファイル、そして有効なら SSH/MCP)を実行できます。認証が無ければ、ポートに到達できる者は誰でもホスト上でコードを実行できることになります。既定では**認証なし**で、ベアメタルでは localhost にバインドしますが、Docker の既定はポートを LAN に公開します。露出したまま放置する前に固めるのは運用者の責任です。以下がそのチェックリストです。 ## 1. ネットワーク公開 -- アプリは既定で `127.0.0.1` にバインドし(ベアメタル)、Docker Compose は `127.0.0.1:9876` のみを公開します。認証と TLS が整うまでこのままに。 -- 公開する際は、バインド/ポート(`server.port`、Compose のポートマッピング)を意図的に変更し、TLS を前段に置きます(MAESTRO のネイティブ HTTPS `server.tls` か、リバースプロキシ)。 +- アプリはベアメタルでは既定で `127.0.0.1` にバインドしますが、Docker Compose は `9876` を**全インターフェース**に公開するため、Docker デプロイは初期状態から LAN 到達可能・認証なしです。共有ネットワークに置いたままにする前に認証と TLS を有効化してください。ローカル限定に戻すには Compose のマッピングを `127.0.0.1:9876:9876` に固定します(SSH トンネル経由で到達)。 +- loopback を超えて到達可能な間は、常に TLS を前段に置きます(MAESTRO のネイティブ HTTPS `server.tls` か、リバースプロキシ)。 ## 2. 認証 diff --git a/docs/security-hardening.md b/docs/security-hardening.md index 8db38cf..ee7d418 100644 --- a/docs/security-hardening.md +++ b/docs/security-hardening.md @@ -15,18 +15,20 @@ secrets live and their file permissions, see its *Secrets and Data* section. Anyone who can reach the UI/API can create tasks, and a task can run tools (Bash, web, browser, files, and — if enabled — SSH/MCP). Without authentication -that means anyone who can reach the port can run code on the host. The default -deployment is therefore **localhost-only and unauthenticated**; everything below -is about safely widening that. +that means anyone who can reach the port can run code on the host. It ships +**unauthenticated**: on bare metal it binds to localhost, but the Docker default +publishes the port to your LAN — so securing it before you leave it exposed is +on you. Everything below is that checklist. ## 1. Network exposure -- The app binds to `127.0.0.1` by default (bare metal) and Docker Compose - publishes only `127.0.0.1:9876`. Keep it that way until auth and TLS are in - place. -- When you do expose it, change the bind/port deliberately (`server.port`, the - Compose port mapping) and front it with TLS — either MAESTRO's native HTTPS - (`server.tls`) or a reverse proxy. +- The app binds to `127.0.0.1` by default on bare metal, but Docker Compose + publishes `9876` on **all interfaces** by default — so a Docker deployment is + LAN-reachable and unauthenticated out of the box. Enable auth and TLS before + leaving it on any shared network, or pin the Compose mapping to + `127.0.0.1:9876:9876` (and reach it over an SSH tunnel) to keep it local-only. +- Front it with TLS — either MAESTRO's native HTTPS (`server.tls`) or a reverse + proxy — whenever it is reachable beyond loopback. ## 2. Authentication diff --git a/docs/tools/requestpackage.md b/docs/tools/requestpackage.md new file mode 100644 index 0000000..89a5436 --- /dev/null +++ b/docs/tools/requestpackage.md @@ -0,0 +1,53 @@ +# RequestPackage + +Request a Python wheel that is not already available, so an approver can install +it into **this workspace** and let the run continue. + +## When to use it + +You tried to `import somelib` (in `Bash` running `python3`, or `RunUserScript`) +and it failed with `ModuleNotFoundError`, and the library is **not** in the +preinstalled set. Instead of retrying `pip install` (which is blocked in the +sandbox), call: + +``` +RequestPackage({ name: "httpx", reason: "need an async HTTP client for the API calls" }) +RequestPackage({ name: "pandas==2.2.2", reason: "pinned for reproducibility" }) +``` + +- `name` — the package. Either a bare name (`httpx`) or an exact pin + (`httpx==0.27.0`). **Only `name==version` pins are accepted** — ranges + (`>=`, `~=`) and URLs/extras are rejected (the overlay must be reproducible). +- `reason` — a concrete justification. It is shown to the approver. + +## What happens + +- **Interactive runs (a user is watching the chat):** the movement pauses and an + Approve / Deny card appears in the chat. When a task-write user (owner / admin / + space editor) approves, the wheel is installed into this workspace's private + overlay and the run resumes automatically — now `import` works. If denied, you + continue without it. +- **Non-interactive runs (subtasks / scheduled):** the request is recorded (the + user finds it later) and you proceed **without** the package. Don't block on it — + finish with what you have, or `complete({status:"needs_user_input"})` if you + genuinely can't proceed. + +## Rules and limits + +- Wheels only, from a fixed package index. No source builds, no arbitrary index. +- A package that would shadow the standard library or a preinstalled package + (e.g. `os`, `numpy`, `pandas`) is rejected — those are already importable. +- If the package is already installed in this workspace, the tool tells you to + just `import` it (no approval needed). +- The install is per-workspace. Other workspaces do not see it. +- You cannot approve your own request — approval is a human/operator action. + +## After approval + +The run re-enters the same step. Re-run your Python; the import now succeeds. If +you call `RequestPackage` again for the same package, it reports "already +installed". + +Related: preinstalled packages are listed in the error you get from a blocked +`pip install`. Workspace-wide package management (for operators) lives in +**Settings → the workspace's Python packages panel**. diff --git a/docs/tools/searchtaskconversation.md b/docs/tools/searchtaskconversation.md index b070a46..32c84e7 100644 --- a/docs/tools/searchtaskconversation.md +++ b/docs/tools/searchtaskconversation.md @@ -13,7 +13,7 @@ | 名前 | 必須 | 説明 | |------|------|------| -| `query` | ○ | 検索キーワード。部分一致・大文字小文字は無視 | +| `query` | ○ | 検索キーワード。スペース区切りで複数指定すると AND 検索(すべての語を含む発言だけがヒット)。各語は部分一致・大文字小文字は無視 | | `source` | | `comments` / `transcript` / `both`(既定 `both`) | | `author` | | `user` / `agent` / `system` で発言者を絞る | | `kind` | | コメント種別で絞る(request/comment/interjection/result/ask/progress/handoff)。transcript には適用されない | diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 004c5f1..1e058b5 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -13,8 +13,8 @@ Piece の取得・編集には GetPiece / CreatePiece / UpdatePiece を使う。 ## ツール -- **InstallSkill** — スキルを保存する。通常は `content` に SKILL.md 全文(YAML frontmatter + 本文)を渡す。workspace 内に `SKILL.md` と `scripts/` 等を含むディレクトリを組み立て済みなら `sourcePath`(workspace 内の絶対パス)を渡す。`scope` は `user`(個人 or 共有ワークスペース)か `system`(全ユーザー共有・admin のみ)。 -- **ReadSkill** — スキル本文を取得する。ディレクトリ型スキルは workspace の `skills/{name}/` に展開され、その相対パスでスクリプトを実行できる。 +- **InstallSkill** — スキルを保存する。通常は `content` に SKILL.md 全文(YAML frontmatter + 本文)を渡す。workspace 内に `SKILL.md` と `scripts/` 等を含むディレクトリを組み立て済みなら `sourcePath` に **workspace 相対パス**(例: `output/my-skill`)を渡す。ホスト上のフルパスは不要(パス中に workspace の実配置(`space/{id}/files` 等)が含まれていれば自動で workspace 相対に読み替える)。`scope` は `user`(個人 or 共有ワークスペース)か `system`(全ユーザー共有・admin のみ)。**`name` パラメータは SKILL.md frontmatter の `name:` と完全一致が必須**(不一致だと一覧上の表示名と保存フォルダ名がズレて UI から管理不能になるため、インストール自体を拒否する)。frontmatter に有効な `name:`(`[a-z0-9_-]`)が無い SKILL.md も拒否される。 +- **ReadSkill** — スキル本文を取得する。ディレクトリ型スキルは workspace の `skills/{name}/` に展開され、その相対パスでスクリプトを実行できる。展開済みでもスキル元が更新されていれば、次の ReadSkill でコピーを丸ごと最新版に置き換える(元が未変更なら workspace 内コピーへの手直しは維持される)。 - **ListSkills** — インストール済みスキルの一覧を返す。 ## スコープと可視性(重要) diff --git a/docs/tools/xsearch.md b/docs/tools/xsearch.md index 689f075..9632bff 100644 --- a/docs/tools/xsearch.md +++ b/docs/tools/xsearch.md @@ -87,13 +87,39 @@ XTimeline({ ```js XPostDetail({ - url: "https://twitter.com/.../status/1234567890", - // または status_id: "1234567890" + tweet: "https://x.com/.../status/1234567890", + // tweet ID だけでも、長文記事 URL (https://x.com/.../article/1234567890) でも可 }) ``` 返り値にはリプライツリーが含まれる。議論の流れを追いたいときに使う。 +### X 長文記事(X Articles) + +対象が長文記事ポストの場合、`text` は記事への t.co リンク 1 本だけになる。 +記事の中身は `article` キーに入る: + +```yaml +data: + - id: '1234567890' + text: https://t.co/xxxx # 記事ポストの本文はリンクのみ + article: + title: 記事タイトル + previewText: 冒頭のプレビュー文 + plainText: 記事本文の全文(プレーンテキスト) + publishedAtISO: '2026-07-02T00:04:51+00:00' + coverImageUrl: https://pbs.twimg.com/media/XXXX.jpg +``` + +- `plainText` はデフォルトで 12,000 文字で切り詰め、`plainTextTruncated: true` が付く。 + 全文が必要なら `full_text: true` を渡す +- **記事内の埋め込み画像・動画は `media[]` に合流する**(先頭 20 件まで)。通常の + 投稿メディアと同じく自動ダウンロードされ `localPath` が付くので、そのまま + ReadImage / AnnotateImage に渡せる。動画の扱いは `tools.x_download_video` に従う +- カバー画像(`coverImageUrl`)は自動ダウンロード対象外。必要なら DownloadFile に渡す +- 記事ページ(`x.com/{user}/article/{id}`)を WebFetch で読むことはできない + (ログイン壁で失敗する)。記事は必ずこのツールで取得する + ## 出力フォーマット - デフォルト: 構造化テキスト(投稿者・本文・いいね数等) diff --git a/scripts/x-adapter/test_x_adapter.py b/scripts/x-adapter/test_x_adapter.py index af97222..901fb28 100644 --- a/scripts/x-adapter/test_x_adapter.py +++ b/scripts/x-adapter/test_x_adapter.py @@ -29,6 +29,7 @@ def ns(**kw): check("ref: bare id", xa.parse_tweet_ref("20") == "20") check("ref: x.com url", xa.parse_tweet_ref("https://x.com/jack/status/20") == "20") check("ref: twitter.com url", xa.parse_tweet_ref("https://twitter.com/jack/statuses/20?s=1") == "20") +check("ref: article url", xa.parse_tweet_ref("https://x.com/jack/article/2072471529242407210") == "2072471529242407210") check("ref: junk -> None", xa.parse_tweet_ref("not a tweet") is None) check("ref: empty -> None", xa.parse_tweet_ref("") is None) @@ -162,6 +163,103 @@ check("home: who-to-follow user skipped", xa.parse_home_timeline( {"entryId": "cursor-top", "content": {"cursorType": "Top"}}, ]}]}}}}, limit=10) == []) +# ── find_tweet_result_in_detail / extract_article (X 長文記事) ── +# TweetDetail レスポンスの実構造を模した fixture (2026-07 実データの縮約) +article_result = { + "rest_id": "500", + "legacy": {"full_text": "https://t.co/xxxx", "created_at": "Wed Oct 10 20:19:24 +0000 2018", + "favorite_count": 1, "retweet_count": 0, "reply_count": 0}, + "article": {"article_results": {"result": { + "rest_id": "499", + "title": "記事タイトル", + "preview_text": "プレビュー文", + "plain_text": "本文です。" * 10, + "metadata": {"first_published_at_secs": 1782950691}, + "cover_media": {"media_info": {"original_img_url": "https://pbs.twimg.com/media/COVER.jpg"}}, + }}}, +} +detail_payload = {"data": {"threaded_conversation_with_injections_v2": {"instructions": [ + {"type": "TimelineAddEntries", "entries": [ + {"entryId": "tweet-1", "content": {"itemContent": {"tweet_results": {"result": {"rest_id": "1", "legacy": {}}}}}}, + {"entryId": "tweet-500", "content": {"itemContent": {"tweet_results": {"result": article_result}}}}, + ]}, +]}}} +found = xa.find_tweet_result_in_detail(detail_payload, "500") +check("detail: focal tweet found", found is not None and found.get("rest_id") == "500") +check("detail: missing id -> None", xa.find_tweet_result_in_detail(detail_payload, "999") is None) +check("detail: empty payload -> None", xa.find_tweet_result_in_detail({}, "500") is None) + +# visibility wrapper 越しでも見つかる +wrapped_payload = {"data": {"entries": [{"tweet_results": {"result": { + "__typename": "TweetWithVisibilityResults", "tweet": article_result}}}]} +} +found_w = xa.find_tweet_result_in_detail(wrapped_payload, "500") +check("detail: visibility wrapper unwrapped", found_w is not None and found_w.get("rest_id") == "500") + +art = xa.extract_article(article_result) +check("article: title", art["title"] == "記事タイトル") +check("article: previewText", art["previewText"] == "プレビュー文") +check("article: plainText", art["plainText"] == "本文です。" * 10) +check("article: publishedAtISO", art["publishedAtISO"] == "2026-07-02T00:04:51+00:00") +check("article: coverImageUrl", art["coverImageUrl"] == "https://pbs.twimg.com/media/COVER.jpg") +check("article: not truncated", "plainTextTruncated" not in art) + +# cap: full_text=False では ARTICLE_TEXT_CAP で切る +long_art = {"article": {"article_results": {"result": { + "title": "t", "plain_text": "あ" * (xa.ARTICLE_TEXT_CAP + 100)}}}} +capped = xa.extract_article(long_art) +check("article: capped length", len(capped["plainText"]) == xa.ARTICLE_TEXT_CAP) +check("article: truncated flag", capped.get("plainTextTruncated") is True) +full = xa.extract_article(long_art, full_text=True) +check("article: full_text lifts cap", len(full["plainText"]) == xa.ARTICLE_TEXT_CAP + 100) +check("article: full not truncated", "plainTextTruncated" not in full) + +# 記事なしツイート → None / 壊れた形 → None +check("article: non-article -> None", xa.extract_article(result_legacy) is None) +check("article: junk -> None", xa.extract_article({"article": {"article_results": {"result": "?"}}}) is None) +check("article: non-dict -> None", xa.extract_article(None) is None) + +# plain_text 無し (fieldToggles 未対応時) でも title/preview は返す +no_body = {"article": {"article_results": {"result": {"title": "t2", "preview_text": "p2"}}}} +nb = xa.extract_article(no_body) +check("article: no body still returns meta", nb["title"] == "t2" and "plainText" not in nb) + +# ── extract_article_media (記事内の埋め込み画像・動画) ── +img_entity = {"media_id": "111", "media_info": { + "__typename": "ApiImage", + "original_img_url": "https://pbs.twimg.com/media/IMG1.jpg", + "original_img_width": 1983, "original_img_height": 793}} +vid_entity = {"media_id": "222", "media_info": { + "__typename": "ApiVideo", + "duration_millis": 7658, + "preview_image": {"original_img_url": "https://pbs.twimg.com/amplify_video_thumb/222/img/P.jpg"}, + "variants": [ + {"bit_rate": 2176000, "content_type": "video/mp4", "url": "https://video.twimg.com/a/720.mp4"}, + {"content_type": "application/x-mpegURL", "url": "https://video.twimg.com/a/pl.m3u8"}, + ]}} +gif_entity = {"media_id": "333", "media_info": { + "__typename": "ApiGif", + "preview_image": {"original_img_url": "https://pbs.twimg.com/tweet_video_thumb/G.jpg"}, + "variants": [{"content_type": "video/mp4", "url": "https://video.twimg.com/tweet_video/g.mp4"}]}} +unknown_entity = {"media_id": "444", "media_info": {"__typename": "ApiAudioSpace"}} +art_with_media = {"article": {"article_results": {"result": { + "title": "t", "media_entities": [img_entity, vid_entity, gif_entity, unknown_entity]}}}} +am = xa.extract_article_media(art_with_media) +check("amedia: count (unknown skipped)", len(am) == 3) +check("amedia: photo", am[0] == {"type": "photo", "url": "https://pbs.twimg.com/media/IMG1.jpg"}) +check("amedia: video poster", am[1]["type"] == "video" and am[1]["url"].endswith("P.jpg")) +check("amedia: video variant mapped", am[1]["variants"][0] == + {"url": "https://video.twimg.com/a/720.mp4", "bitrate": 2176000, "contentType": "video/mp4"}) +check("amedia: m3u8 variant kept", am[1]["variants"][1]["contentType"] == "application/x-mpegURL") +check("amedia: gif -> animated_gif", am[2]["type"] == "animated_gif" and am[2]["variants"][0]["url"].endswith("g.mp4")) +check("amedia: non-article -> []", xa.extract_article_media(result_legacy) == []) +check("amedia: no media_entities -> []", xa.extract_article_media(no_body) == []) +check("amedia: non-dict -> []", xa.extract_article_media(None) == []) + +# 件数 cap: ARTICLE_MEDIA_CAP を超えたら切る +many = {"article": {"article_results": {"result": {"media_entities": [img_entity] * (xa.ARTICLE_MEDIA_CAP + 5)}}}} +check("amedia: capped", len(xa.extract_article_media(many)) == xa.ARTICLE_MEDIA_CAP) + # ── emit (optional, needs PyYAML) ── try: import io diff --git a/scripts/x-adapter/x_adapter.py b/scripts/x-adapter/x_adapter.py index 3ffbff1..e69a268 100644 --- a/scripts/x-adapter/x_adapter.py +++ b/scripts/x-adapter/x_adapter.py @@ -27,6 +27,8 @@ - id, text, author{name,screenName,profileImageUrl,id}, metrics{likes,retweets,replies,views}, createdAtISO, isRetweet, media[]{type, url, variants[]{url,bitrate,contentType}} + article{title, previewText, plainText, ...} # X 長文記事のみ (tweet サブコマンド) + # 長文記事の埋め込み画像・動画は media[] に合流する (自動 DL 対象) 注意: twscrape / twikit / httpx は遅延 import (関数内)。pure なマッピング関数だけ なら依存なしで import でき、test_x_adapter.py がネットワーク・依存なしで回る。 @@ -39,7 +41,7 @@ import re import sys from email.utils import parsedate_to_datetime -VERSION = "x-adapter 1.0.0 (twscrape backend)" +VERSION = "x-adapter 1.2.0 (twscrape backend)" # twitter-cli の YAML スキーマバージョン (x.ts は使わないが互換のため踏襲) SCHEMA_VERSION = "1" @@ -48,13 +50,17 @@ SCHEMA_VERSION = "1" # ── pure helpers (依存なし・テスト対象) ────────────────────────────── def parse_tweet_ref(raw: str) -> str | None: - """tweet ID もしくは status URL から数値 ID を取り出す。""" + """tweet ID もしくは status / article URL から数値 ID を取り出す。 + + X の長文記事 (X Articles) は `x.com/{user}/article/{id}` 形式で共有されるが、 + その id はツイート ID と同じ空間なので TweetDetail でそのまま引ける。 + """ s = (raw or "").strip() if not s: return None if s.isdigit(): return s - m = re.search(r"(?:x\.com|twitter\.com)/[^/]+/status(?:es)?/(\d+)", s) + m = re.search(r"(?:x\.com|twitter\.com)/[^/]+/(?:status(?:es)?|article)/(\d+)", s) return m.group(1) if m else None @@ -219,6 +225,113 @@ def parse_graphql_tweet_result(result: dict) -> dict | None: } +# ── X 長文記事 (X Articles) の抽出 ────────────────────────────────── +# +# 長文記事ポストの legacy.full_text は記事への t.co リンク 1 本だけで本文を含まない。 +# 本文は TweetDetail レスポンスの tweet result 直下 `article.article_results.result` +# に入る (plain_text は fieldToggles.withArticlePlainText: true を付けた時のみ)。 + +# plain_text の既定上限 (文字数)。LLM コンテキスト保護。--full-text で解除。 +ARTICLE_TEXT_CAP = 12000 + +# 記事内埋め込みメディアの件数上限。x.ts の downloadTweetMedia は media[] を +# 全件 DL する (件数制限なし・サイズ上限のみ) ので、画像だらけの記事で +# ダウンロードが暴発しないようアダプタ側で頭を抑える。 +ARTICLE_MEDIA_CAP = 20 + + +def find_tweet_result_in_detail(payload, tid: str) -> dict | None: + """TweetDetail レスポンス全体から rest_id == tid の tweet_results.result を探す。 + + conversation スレッド内の別ツイート (リプライ等) も同じ形で並ぶので、 + rest_id の一致で focal tweet を特定する。visibility wrapper は剥がして返す。 + """ + if isinstance(payload, dict): + tr = payload.get("tweet_results") + if isinstance(tr, dict): + r = tr.get("result") + if isinstance(r, dict): + if r.get("__typename") == "TweetWithVisibilityResults": + r = r.get("tweet") if isinstance(r.get("tweet"), dict) else r + if isinstance(r, dict) and str(r.get("rest_id") or "") == str(tid): + return r + for v in payload.values(): + hit = find_tweet_result_in_detail(v, tid) + if hit is not None: + return hit + elif isinstance(payload, list): + for v in payload: + hit = find_tweet_result_in_detail(v, tid) + if hit is not None: + return hit + return None + + +def extract_article(result, full_text: bool = False) -> dict | None: + """tweet result の article.article_results.result から記事情報を YAML dict に。 + + 記事でないツイート・形が崩れている場合は None (呼び出し側は無視して従来出力)。 + """ + art = _dig(result if isinstance(result, dict) else {}, "article", "article_results", "result") + if not isinstance(art, dict): + return None + out: dict = { + "title": art.get("title") or "", + "previewText": art.get("preview_text") or "", + } + plain = art.get("plain_text") + if isinstance(plain, str) and plain: + if not full_text and len(plain) > ARTICLE_TEXT_CAP: + out["plainText"] = plain[:ARTICLE_TEXT_CAP] + out["plainTextTruncated"] = True + else: + out["plainText"] = plain + pub = _dig(art, "metadata", "first_published_at_secs") + if isinstance(pub, (int, float)) and pub > 0: + from datetime import datetime, timezone + out["publishedAtISO"] = datetime.fromtimestamp(int(pub), tz=timezone.utc).isoformat() + cover = _dig(art, "cover_media", "media_info", "original_img_url") + if cover: + out["coverImageUrl"] = cover + return out + + +def extract_article_media(result) -> list: + """記事内の埋め込みメディア (media_entities) を row の media[] と同じ形に変換。 + + media_entities は fieldToggles.withArticleRichContentState: true の時のみ返る。 + row["media"] に合流させることで x.ts の downloadTweetMedia がそのまま + 自動 DL し localPath を付ける (記事ポストの tweet-level media は常に空なので + 衝突しない)。未知の __typename (音声スペース等) は黙ってスキップ。 + """ + art = _dig(result if isinstance(result, dict) else {}, "article", "article_results", "result") + if not isinstance(art, dict): + return [] + out: list = [] + for entity in art.get("media_entities") or []: + if len(out) >= ARTICLE_MEDIA_CAP: + break + info = _dig(_as_dict(entity), "media_info") or {} + tname = info.get("__typename") + if tname == "ApiImage": + url = info.get("original_img_url") + if url: + out.append({"type": "photo", "url": url}) + elif tname in ("ApiVideo", "ApiGif"): + variants = [ + {"url": v.get("url"), "bitrate": v.get("bit_rate") or 0, + "contentType": v.get("content_type") or ""} + for v in (info.get("variants") or []) + if isinstance(v, dict) and v.get("url") + ] + out.append({ + "type": "video" if tname == "ApiVideo" else "animated_gif", + "url": _dig(info, "preview_image", "original_img_url") or "", + "variants": variants, + }) + return out + + def _collect_tweet_results(node, out: list, limit: int) -> None: """entry / module item から tweet_results.result を拾って out に積む。 @@ -405,17 +518,77 @@ async def cmd_user_posts(args) -> None: shutil.rmtree(tmpdir, ignore_errors=True) +async def _tweet_detail_raw_with_article(api, tid: str): + """TweetDetail を fieldToggles.withArticlePlainText 付きで直接叩く。 + + twscrape の tweet_details_raw は fieldToggles を渡せない (_gql_item が + variables/features のみ) ので、同じ op / variables / QueueClient を使い + パラメータだけ足す。X 長文記事の本文 (plain_text) は withArticlePlainText、 + 記事内埋め込みメディア (media_entities) は withArticleRichContentState が + 無いとレスポンスに含まれない (後者は content_state も同乗するが emit しない)。 + """ + from twscrape.api import GQL_URL, OP_TweetDetail + from twscrape.api import GQL_FEATURES # type: ignore[attr-defined] + from twscrape.queue_client import QueueClient + from twscrape.utils import encode_params + + kv = { + "focalTweetId": str(tid), + "with_rux_injections": True, + "includePromotedContent": True, + "withCommunity": True, + "withQuickPromoteEligibilityTweetFields": True, + "withBirdwatchNotes": True, + "withVoice": True, + "withV2Timeline": True, + } + params = { + "variables": kv, + "features": GQL_FEATURES, + "fieldToggles": {"withArticlePlainText": True, "withArticleRichContentState": True}, + } + queue = OP_TweetDetail.split("/")[-1] + async with QueueClient(api.pool, queue, False) as client: + return await client.get(f"{GQL_URL}/{OP_TweetDetail}", params=encode_params(params)) + + async def cmd_tweet(args) -> None: tid = parse_tweet_ref(args.tweet) if not tid: fail(f"x-adapter: could not parse tweet id from '{args.tweet}'") api, tmpdir, shutil = await _make_api() try: - t = await api.tweet_details(int(tid)) + # 記事本文トグル付きの直接呼び出しを優先。twscrape 内部 API の変化などで + # 壊れたら従来の tweet_details に落として XPostDetail 自体は生かす。 + t = None + article = None + article_media: list = [] + try: + from twscrape.models import parse_tweet + rep = await _tweet_detail_raw_with_article(api, tid) + if rep is not None: + t = parse_tweet(rep, int(tid)) + result = find_tweet_result_in_detail(rep.json(), tid) + article = extract_article(result, full_text=bool(args.full_text)) + article_media = extract_article_media(result) + except SystemExit: + raise + except Exception as e: + print(f"x-adapter: article-aware fetch failed, falling back: {type(e).__name__}: {e}", + file=sys.stderr) + if not t: + t = await api.tweet_details(int(tid)) if not t: await _ensure_alive(api) # 認証切れなら nonzero fail(f"x-adapter: tweet {tid} not found or inaccessible") - emit([twscrape_tweet_to_dict(t)]) + row = twscrape_tweet_to_dict(t) + if article: + row["article"] = article + if article_media: + # 記事ポストの tweet-level media は空なので合流しても衝突しない。 + # x.ts の downloadTweetMedia がここを見て自動 DL → localPath 付与する。 + row["media"] = (row.get("media") or []) + article_media + emit([row]) finally: shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/src/bridge/__snapshots__/server-route-order.test.ts.snap b/src/bridge/__snapshots__/server-route-order.test.ts.snap new file mode 100644 index 0000000..e3d2c34 --- /dev/null +++ b/src/bridge/__snapshots__/server-route-order.test.ts.snap @@ -0,0 +1,239 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`createCoreServer route registration order > pins the auth-active + ConfigManager boot layer order 1`] = ` +[ + "USE name=query path=^\\/?(?=\\/|$)", + "USE name=expressInit path=^\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "ROUTE get /metrics", + "USE name=jsonParser path=^\\/api\\/config\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/pieces\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/memory\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/reflection\\/?(?=\\/|$)", + "USE name=session path=^\\/?(?=\\/|$)", + "USE name=initialize path=^\\/?(?=\\/|$)", + "USE name=authenticate path=^\\/?(?=\\/|$)", + "USE name=router path=^\\/auth\\/?(?=\\/|$)", + "ROUTE get /api/auth/me", + "ROUTE post /api/auth/password", + "USE name=requireAuth path=^\\/api\\/local\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/repos\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/workers\\/?(?=\\/|$)", + "USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)", + "USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)", + "ROUTE get /api/admin/users", + "ROUTE post /api/admin/users", + "ROUTE post /api/admin/users/:id/password", + "ROUTE patch /api/admin/users/:id", + "ROUTE delete /api/admin/users/:id", + "ROUTE get /api/admin/orgs", + "ROUTE post /api/admin/orgs", + "ROUTE patch /api/admin/orgs/:id", + "ROUTE delete /api/admin/orgs/:id", + "ROUTE post /api/admin/orgs/:id/members", + "ROUTE delete /api/admin/orgs/:id/members/:userId", + "USE name=jsonParser path=^\\/api\\/admin\\/gateway\\/keys\\/?(?=\\/|$)", + "USE name=requireAdmin path=^\\/api\\/admin\\/gateway\\/keys\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/admin\\/gateway\\/keys\\/?(?=\\/|$)", + "ROUTE get /api/users/me/orgs", + "ROUTE patch /api/users/me/preferences", + "ROUTE get /api/users/pickable", + "ROUTE get /api/shared/:token", + "ROUTE get /api/shared/:token/comments", + "ROUTE get /api/shared/:token/files", + "ROUTE get /api/shared/:token/files/content", + "ROUTE get /api/shared/:token/files/raw", + "ROUTE get /api/shared/:token/subtasks/activities", + "ROUTE get /api/shared/:token/subtasks/:jobId/activity", + "ROUTE get /api/shared/:token/subtasks/:jobId/files", + "ROUTE get /api/shared/:token/subtasks/:jobId/files/*", + "ROUTE post /api/local/tasks/:taskId/share", + "ROUTE delete /api/local/tasks/:taskId/share", + "ROUTE get /api/app-share/:token", + "ROUTE get /api/app-share/:token/files/content", + "ROUTE get /api/app-share/:token/files/raw", + "ROUTE get /api/app-share/:token/files/list", + "ROUTE get /", + "ROUTE get /api/version", + "ROUTE get /api/repos", + "ROUTE get /api/local/tasks", + "ROUTE post /api/local/tasks", + "ROUTE get /api/local/tasks/:taskId", + "ROUTE patch /api/local/tasks/:taskId", + "ROUTE delete /api/local/tasks/:taskId", + "ROUTE get /api/local/tasks/:taskId/tool-requests", + "ROUTE post /api/local/tasks/:taskId/tool-requests/:reqId/decide", + "ROUTE put /api/local/tasks/:taskId/feedback", + "ROUTE put /api/local/tasks/:taskId/mission", + "ROUTE get /api/local/tasks/:taskId/comments", + "ROUTE post /api/local/tasks/:taskId/comments", + "ROUTE post /api/local/tasks/:taskId/cancel", + "ROUTE post /api/local/tasks/:taskId/continue", + "ROUTE post /api/local/tasks/:taskId/regenerate-title", + "ROUTE post /api/local/tasks/evaluate-prompt", + "ROUTE get /api/local/tasks/:taskId/stream", + "ROUTE get /api/local/tasks/:taskId/files", + "ROUTE get /api/local/tasks/:taskId/files/content", + "ROUTE get /api/local/tasks/:taskId/files/provenance", + "ROUTE get /api/local/tasks/:taskId/files/raw", + "ROUTE get /api/local/tasks/:taskId/files/office-preview", + "ROUTE put /api/local/tasks/:taskId/files/content", + "ROUTE post /api/local/tasks/:taskId/files/upload", + "ROUTE post /api/local/tasks/:taskId/files/delete", + "ROUTE post /api/local/tasks/:taskId/files/download-zip", + "USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)", + "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files", + "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*", + "ROUTE get /api/jobs/:jobId", + "ROUTE get /api/setup/status", + "ROUTE post /api/setup/probe", + "ROUTE post /api/setup/apply", + "ROUTE get /api/config", + "ROUTE put /api/config", + "ROUTE post /api/config/reload", + "ROUTE get /api/workers", + "ROUTE get /api/workers/:workerId/backends", + "ROUTE get /api/branding", + "USE name=serveStatic path=^\\/branding\\/?(?=\\/|$)", + "ROUTE post /api/branding/upload", + "ROUTE delete /api/branding/upload", + "ROUTE get /api/tools", + "ROUTE get /api/notifications/vapid-public-key", + "ROUTE get /api/notifications/subscriptions", + "ROUTE post /api/notifications/subscriptions", + "ROUTE delete /api/notifications/subscriptions/:id", + "ROUTE get /api/notifications/preferences", + "ROUTE put /api/notifications/preferences", + "ROUTE post /api/notifications/preferences/migrate-from-localstorage", + "ROUTE post /api/notifications/test", + "USE name=jsonParser path=^\\/api\\/local\\/browser\\/sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/browser\\/sessions\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/browser-sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/browser-sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/users\\/me\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/memory\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/reflection\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/spaces\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "ROUTE get /health", + "USE name=requireAdmin path=^\\/api\\/admin\\/gateway\\/status\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/admin\\/gateway\\/status\\/?(?=\\/|$)", + "USE name=router path=^\\/novnc\\/?(?=\\/|$)", +] +`; + +exports[`createCoreServer route registration order > pins the no-auth minimal boot layer order 1`] = ` +[ + "USE name=query path=^\\/?(?=\\/|$)", + "USE name=expressInit path=^\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "ROUTE get /metrics", + "USE name=jsonParser path=^\\/api\\/config\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/pieces\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/memory\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/reflection\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)", + "ROUTE get /api/admin/users", + "ROUTE post /api/admin/users", + "ROUTE post /api/admin/users/:id/password", + "ROUTE patch /api/admin/users/:id", + "ROUTE delete /api/admin/users/:id", + "ROUTE get /api/admin/orgs", + "ROUTE post /api/admin/orgs", + "ROUTE patch /api/admin/orgs/:id", + "ROUTE delete /api/admin/orgs/:id", + "ROUTE post /api/admin/orgs/:id/members", + "ROUTE delete /api/admin/orgs/:id/members/:userId", + "ROUTE get /api/users/me/orgs", + "ROUTE patch /api/users/me/preferences", + "ROUTE get /api/users/pickable", + "ROUTE get /api/shared/:token", + "ROUTE get /api/shared/:token/comments", + "ROUTE get /api/shared/:token/files", + "ROUTE get /api/shared/:token/files/content", + "ROUTE get /api/shared/:token/files/raw", + "ROUTE get /api/shared/:token/subtasks/activities", + "ROUTE get /api/shared/:token/subtasks/:jobId/activity", + "ROUTE get /api/shared/:token/subtasks/:jobId/files", + "ROUTE get /api/shared/:token/subtasks/:jobId/files/*", + "ROUTE post /api/local/tasks/:taskId/share", + "ROUTE delete /api/local/tasks/:taskId/share", + "ROUTE get /api/app-share/:token", + "ROUTE get /api/app-share/:token/files/content", + "ROUTE get /api/app-share/:token/files/raw", + "ROUTE get /api/app-share/:token/files/list", + "ROUTE get /", + "ROUTE get /api/version", + "ROUTE get /api/repos", + "ROUTE get /api/local/tasks", + "ROUTE post /api/local/tasks", + "ROUTE get /api/local/tasks/:taskId", + "ROUTE patch /api/local/tasks/:taskId", + "ROUTE delete /api/local/tasks/:taskId", + "ROUTE get /api/local/tasks/:taskId/tool-requests", + "ROUTE post /api/local/tasks/:taskId/tool-requests/:reqId/decide", + "ROUTE put /api/local/tasks/:taskId/feedback", + "ROUTE put /api/local/tasks/:taskId/mission", + "ROUTE get /api/local/tasks/:taskId/comments", + "ROUTE post /api/local/tasks/:taskId/comments", + "ROUTE post /api/local/tasks/:taskId/cancel", + "ROUTE post /api/local/tasks/:taskId/continue", + "ROUTE post /api/local/tasks/:taskId/regenerate-title", + "ROUTE post /api/local/tasks/evaluate-prompt", + "ROUTE get /api/local/tasks/:taskId/stream", + "ROUTE get /api/local/tasks/:taskId/files", + "ROUTE get /api/local/tasks/:taskId/files/content", + "ROUTE get /api/local/tasks/:taskId/files/provenance", + "ROUTE get /api/local/tasks/:taskId/files/raw", + "ROUTE get /api/local/tasks/:taskId/files/office-preview", + "ROUTE put /api/local/tasks/:taskId/files/content", + "ROUTE post /api/local/tasks/:taskId/files/upload", + "ROUTE post /api/local/tasks/:taskId/files/delete", + "ROUTE post /api/local/tasks/:taskId/files/download-zip", + "USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)", + "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files", + "ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*", + "ROUTE get /api/jobs/:jobId", + "ROUTE get /api/branding", + "USE name=serveStatic path=^\\/branding\\/?(?=\\/|$)", + "ROUTE post /api/branding/upload", + "ROUTE delete /api/branding/upload", + "ROUTE get /api/tools", + "ROUTE get /api/notifications/vapid-public-key", + "ROUTE get /api/notifications/subscriptions", + "ROUTE post /api/notifications/subscriptions", + "ROUTE delete /api/notifications/subscriptions/:id", + "ROUTE get /api/notifications/preferences", + "ROUTE put /api/notifications/preferences", + "ROUTE post /api/notifications/preferences/migrate-from-localstorage", + "ROUTE post /api/notifications/test", + "USE name=jsonParser path=^\\/api\\/local\\/browser\\/sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/browser\\/sessions\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/browser-sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/browser-sessions\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/users\\/me\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/memory\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/reflection\\/?(?=\\/|$)", + "USE name= path=^\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/spaces\\/?(?=\\/|$)", + "USE name=jsonParser path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)", + "USE name=router path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)", + "ROUTE get /health", + "USE name=router path=^\\/api\\/admin\\/gateway\\/status\\/?(?=\\/|$)", + "USE name=router path=^\\/novnc\\/?(?=\\/|$)", +] +`; diff --git a/src/bridge/a2a-subsystem.ts b/src/bridge/a2a-subsystem.ts new file mode 100644 index 0000000..f9df483 --- /dev/null +++ b/src/bridge/a2a-subsystem.ts @@ -0,0 +1,74 @@ +import express from 'express'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { Repository } from '../db/repository.js'; +import { logger } from '../logger.js'; +import { loadConfig } from '../config.js'; +import { requireAdmin, DEFAULT_SESSION_SECRET_PATH } from './auth.js'; +import { registerShutdownHook } from './shutdown.js'; +import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js'; +import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js'; +import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js'; +import { mountA2aRequestHandler } from './a2a/request-handler.js'; +import { A2aTaskReconciler } from './a2a/reconciler.js'; +import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js'; + +/** + * A2A OAuth2 Authorization Server + Agent Card + JSON-RPC + admin routers, + * extracted verbatim from createCoreServer (bridge/server.ts). No-op unless + * `a2a.enabled: true` in config.yaml. Registration order relative to the + * other /api/local mounts is preserved by the call site in server.ts. + */ +export function setupA2aSubsystem( + app: express.Express, + deps: { repo: Repository; authActive: boolean }, +): void { + const { repo, authActive } = deps; + + // --- A2A OAuth2 Authorization Server --- + const a2aCfg = (loadConfig() as any).a2a ?? {}; + if (a2aCfg.enabled) { + if (!authActive) { + logger.warn('[a2a-oidc] a2a.enabled but auth is not active; consent flow requires login and will reject all requests'); + } + try { + const sessionSecretPath = loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH; + const cookieKeys = [readFileSync(sessionSecretPath, 'utf-8').trim()]; + const secretsDir = dirname(resolve(sessionSecretPath)); + const provider = createA2aOidcProvider({ + repo, + secretsDir, + issuer: a2aCfg.issuer, + resourceAudience: a2aCfg.resourceAudience, + cookieKeys, + }); + mountA2aOidc(app, provider, { repo, resourceAudience: a2aCfg.resourceAudience }); + app.use('/api/admin/a2a/clients', express.json(), requireAdmin, createA2aClientsAdminRouter(repo, authActive)); + // Agent Card ルータ (base card = public, extended card = self-authenticates via Bearer) + // baseUrl は resourceAudience の origin から導く(例: https://maestro.example.com/a2a → https://maestro.example.com) + // version は '1.0.0' 固定(生成済みバージョンファイルが存在しないため定数を使用) + const a2aBaseUrl = (() => { + try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); } + })(); + app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' })); + // JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由) + // /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。 + mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limits: a2aCfg.limits }); + // bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では + // lease が必要になる(将来課題)。 + const a2aReconciler = new A2aTaskReconciler({ repo }); + a2aReconciler.start(); + registerShutdownHook('a2a-reconciler', async () => { a2aReconciler.stop(); }); + logger.info('[a2a] task reconciler started'); + // スペース A2A スキル許可リスト管理 API (/api/local は requireAuth 配下) + app.use('/api/local/spaces', createSpaceA2aAdminRouter(repo, authActive)); + // A2A 委任一覧・取消 API + // /api/local は server.ts 側で requireAuth 済みのため追加ゲート不要 + app.use('/api/local/a2a/delegations', express.json(), createUserDelegationsRouter(repo, authActive)); + app.use('/api/admin/a2a/delegations', express.json(), requireAdmin, createAdminDelegationsRouter(repo, authActive)); + logger.info('[a2a-oidc] a2a authorization server enabled'); + } catch (err) { + logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`); + } + } +} diff --git a/src/bridge/a2a/a2a-exec-e2e.test.ts b/src/bridge/a2a/a2a-exec-e2e.test.ts index e9ffd4f..4b22371 100644 --- a/src/bridge/a2a/a2a-exec-e2e.test.ts +++ b/src/bridge/a2a/a2a-exec-e2e.test.ts @@ -262,15 +262,17 @@ describe('A2A exec e2e (real token → message/send → job → artifact + negat expect(taskRow.piece_name).toBe('research'); }, 30_000); - it('no bearer → JSON-RPC error, no job created', async () => { + it('no bearer → terminal failed Task, no job created', async () => { const before = jobCountForUser(); const res = await sendMessage(messageSendEnvelope('do research')); - expect(res.status).toBe(200); // jsonRpcHandler は HTTP 200 + JSON-RPC error - const json = await res.json() as { jsonrpc: string; error?: { code: number; message: string }; result?: unknown }; + expect(res.status).toBe(200); + const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { kind?: string; status?: { state: string } } }; expect(json.jsonrpc).toBe('2.0'); - // 未認証 → executor は principal 無しで failed publish → SDK は finalResult 無しで error 化。 - expect(json.error).toBeDefined(); - expect(json.result).toBeUndefined(); + // 未認証 → executor が principal 無しで完全な terminal Task(state=failed) を publish する。 + // (2C-3 Task 4 で SDK ResultManager 互換に修正: 旧挙動は bare status-update が drop され error 化していた) + expect(json.error).toBeUndefined(); + expect(json.result?.kind).toBe('task'); + expect(json.result?.status?.state).toBe('failed'); // ジョブは作られない(最重要のセキュリティ不変条件)。 expect(jobCountForUser()).toBe(before); }, 30_000); @@ -284,12 +286,206 @@ describe('A2A exec e2e (real token → message/send → job → artifact + negat { authorization: `Bearer ${token}` }, ); expect(res.status).toBe(200); - const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { status?: { state: string } } }; + const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { kind?: string; status?: { state: string } } }; expect(json.jsonrpc).toBe('2.0'); - // selectPieceForMessage が null → executor は createLocalTask/createJob 前に failed → error 化。 - expect(json.error).toBeDefined(); - expect(json.result).toBeUndefined(); + // selectPieceForMessage が null → executor は createLocalTask/createJob 前に完全な terminal Task(state=failed) を publish。 + expect(json.error).toBeUndefined(); + expect(json.result?.kind).toBe('task'); + expect(json.result?.status?.state).toBe('failed'); // 委任外スキルでジョブは作られない(fail-closed)。 expect(jobCountForUser()).toBe(before); }, 30_000); }); + +/** + * Task 9 — ガバナンス E2E: 同時実行数上限超過 → rejected Task(rev.1 Critical の再発防止ゲート)。 + * + * maxConcurrentTasks=1 で mount し、該当 grant の非終端 a2a_task を DB に 1 件 seed した状態で + * message/send を叩く。executor の concurrency チェック(tryReserveConcurrency)が + * Math.max(dbCount=1, reserved=0) >= cap=1 を検出し、JSON-RPC result として + * kind='task' / state='rejected' の完全な Task を返す。ジョブは作られない。 + */ +describe('A2A exec e2e: governance — concurrency cap → rejected Task', () => { + let server: http.Server; + let base: string; + let repo: Repository; + + const clientId = 'a2a_gov_e2e'; + const redirectUri = 'https://client.test/cb'; + const userId = 'user-gov'; + const spaceId = 'space-gov'; + + function jobCountForUser(): number { + const row = (repo as any).db + .prepare("SELECT COUNT(*) AS n FROM jobs WHERE owner_id = ?") + .get(userId) as { n: number }; + return row.n; + } + + function sendMessage(body: unknown, headers: Record = {}) { + return fetch(`${base}/a2a`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + } + + async function obtainToken(selectedSpaces: string, selectedSkills: string): Promise { + const audience = `${base}/a2a`; + const client = new Client(); + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash('sha256').update(verifier).digest()); + + const authUrl = `${base}/oidc/auth?` + new URLSearchParams({ + client_id: clientId, response_type: 'code', redirect_uri: redirectUri, + scope: 'openid a2a.read', resource: audience, + code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz', + }); + const authRes = await client.get(authUrl); + expect([302, 303]).toContain(authRes.status); + let loc = authRes.headers.get('location')!; + + for (let i = 0; i < 5; i++) { + if (loc.startsWith(redirectUri)) break; + expect(loc).toContain('/oidc/interaction/'); + const uid = new URL(loc, base).pathname.split('/').pop()!; + const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, { + selectedSpaces, selectedSkills, + }); + expect(confirmRes.status).toBe(200); + const { redirectTo } = await confirmRes.json() as { redirectTo: string }; + expect(redirectTo).toContain('/oidc/auth/'); + const resumeRes = await client.get(redirectTo); + expect([302, 303]).toContain(resumeRes.status); + loc = resumeRes.headers.get('location')!; + } + + expect(loc.startsWith(redirectUri)).toBe(true); + const code = new URL(loc).searchParams.get('code')!; + expect(code).toBeTruthy(); + + const tokenRes = await client.postForm(`${base}/oidc/token`, { + grant_type: 'authorization_code', + code, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri, + }); + expect(tokenRes.status).toBe(200); + const tokenJson = await tokenRes.json() as { access_token: string }; + expect(tokenJson.access_token).toBeTruthy(); + return tokenJson.access_token; + } + + beforeAll(async () => { + repo = new Repository(':memory:'); + + repo.createA2aClient({ + clientId, name: 'Gov E2E Client', redirectUris: [redirectUri], + secretHash: null, tokenEndpointAuthMethod: 'none', + agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin', + }); + + (repo as any).db.prepare( + "INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " + + "VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')", + ).run(userId, 'ugov@test', 'Gov User'); + + (repo as any).db.prepare( + "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Gov Space', 'case', ?, 'private')", + ).run(spaceId, userId); + repo.setSpaceA2aSkills(spaceId, ['research']); + + const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-gov-e2e-')); + const app = express(); + // 擬似ログイン: consent の req.user 用。/a2a は Bearer ベースなので無害。 + app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); }); + + server = http.createServer(app); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const port = (server.address() as any).port; + base = `http://127.0.0.1:${port}`; + const audience = `${base}/a2a`; + + const govProvider = createA2aOidcProvider({ + repo, secretsDir, + issuer: `${base}/oidc`, + resourceAudience: audience, + cookieKeys: ['gov-cookie-key'], + }); + (govProvider as any).proxy = false; + + mountA2aOidc(app, govProvider, { repo, resourceAudience: audience }); + // maxConcurrentTasks=1: 非終端タスクが 1 件あれば次のリクエストは rejected になる。 + mountA2aRequestHandler(app, { + provider: govProvider, repo, + baseUrl: base, + issuer: `${base}/oidc`, + version: '1.0.0-test', + limits: { maxConcurrentTasks: 1 }, + }); + }); + + afterAll(() => { server?.close(); }); + + it('concurrency cap exceeded → rejected Task (kind=task, state=rejected), no job created', async () => { + // Step 1: 実 OAuth フローでトークンを取得(delegation 行が DB に生成される)。 + const token = await obtainToken(spaceId, 'research'); + + // Step 2: 生成された delegation の grant_id を取得。 + const delRow = (repo as any).db + .prepare("SELECT grant_id FROM a2a_delegations WHERE user_id = ? ORDER BY rowid DESC LIMIT 1") + .get(userId) as { grant_id: string } | undefined; + expect(delRow?.grant_id).toBeTruthy(); + const grantId = delRow!.grant_id; + + // Step 3: 非終端 a2a_task を 1 件 seed → countNonTerminalA2aTasksByGrant が 1 を返す。 + // maxConcurrentTasks=1 なので Math.max(dbCount=1, reserved=0) >= 1 → 予約失敗。 + repo.saveA2aTask({ + id: randomUUID(), + contextId: null, + jobId: null, + localTaskId: null, + payload: { + id: randomUUID(), + kind: 'task', + contextId: 'ctx-blocker', + status: { state: 'working', timestamp: new Date().toISOString() }, + }, + grantId, + }); + + const before = jobCountForUser(); + + // Step 4: message/send — 同時実行数上限に到達済みなので rejected になるはず。 + const res = await sendMessage( + { + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: { + message: { + kind: 'message', + messageId: randomUUID(), + role: 'user', + parts: [{ kind: 'text', text: 'do research' }], + }, + }, + }, + { authorization: `Bearer ${token}` }, + ); + expect(res.status).toBe(200); + const json = await res.json() as { + jsonrpc: string; + error?: unknown; + result?: { kind?: string; status?: { state: string } }; + }; + + // Step 5: JSON-RPC error は返らない(SDK ResultManager 互換の完全な Task として返る)。 + // kind=task かつ state=rejected — これが rev.1 Critical が検出できなかったアサーション。 + expect(json.jsonrpc).toBe('2.0'); + expect(json.error).toBeUndefined(); + expect(json.result?.kind).toBe('task'); + expect(json.result?.status?.state).toBe('rejected'); + + // ジョブは作られない(最重要のセキュリティ不変条件)。 + expect(jobCountForUser()).toBe(before); + }, 30_000); +}); diff --git a/src/bridge/a2a/executor.test.ts b/src/bridge/a2a/executor.test.ts index 602333e..05f4fde 100644 --- a/src/bridge/a2a/executor.test.ts +++ b/src/bridge/a2a/executor.test.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { describe, it, expect, vi } from 'vitest'; import { MaestroA2aExecutor } from './executor.js'; +import { A2aLimiter, DEFAULT_A2A_LIMITS } from './limiter.js'; // ── fake helpers ───────────────────────────────────────────────────────────── @@ -115,6 +116,7 @@ function makeFakeRepo(opts: { requestJobCancel: vi.fn().mockReturnValue(true), cancelA2aLinkedJob: vi.fn().mockReturnValue(true), addAuditLog: vi.fn(), + countNonTerminalA2aTasksByGrant: vi.fn().mockReturnValue(0), // Task 5: revocation recheck — return a live delegation by default so existing tests are unaffected getA2aDelegationByGrantId: vi.fn().mockReturnValue({ id: 'deleg-1', @@ -255,16 +257,20 @@ describe('MaestroA2aExecutor', () => { expect(eventBus.finishedCalled).toBe(true); expect(repo.createLocalTask).not.toHaveBeenCalled(); + // Task 4: pre-initial-Task denial must publish a full Task object (kind:'task') + // so the SDK ResultManager can save and return it. A bare status-update would be + // silently dropped, leaving a blocking message/send with no result. const failedEvent = eventBus.published.find( - (e: any) => e.kind === 'status-update' && e.status?.state === 'failed', + (e: any) => e.kind === 'task' && e.status?.state === 'failed', ); expect(failedEvent).toBeDefined(); - expect((failedEvent as any).final).toBe(true); + // Must NOT be a bare status-update + expect(eventBus.published.some((e: any) => e.kind === 'status-update')).toBe(false); }); }); describe('execute — no principal', () => { - it('context に a2aPrincipal が無い → failed + finished', async () => { + it('context に a2aPrincipal が無い → failed Task (kind:task) + finished', async () => { const repo = makeFakeRepo(); const eventBus = makeFakeEventBus(); const executor = new MaestroA2aExecutor(repo as any, { @@ -277,10 +283,14 @@ describe('MaestroA2aExecutor', () => { await executor.execute(ctx as any, eventBus as any); expect(eventBus.finishedCalled).toBe(true); + // Task 4: pre-initial-Task denial → full Task object, NOT a bare status-update const failedEvent = eventBus.published.find( - (e: any) => e.kind === 'status-update' && e.status?.state === 'failed', + (e: any) => e.kind === 'task' && e.status?.state === 'failed', ); expect(failedEvent).toBeDefined(); + expect(failedEvent).toMatchObject({ id: 'task-1', contextId: 'ctx-1', kind: 'task' }); + // Must NOT be a bare status-update + expect(eventBus.published.some((e: any) => e.kind === 'status-update')).toBe(false); // createLocalTask は呼ばれない expect(repo.createLocalTask).not.toHaveBeenCalled(); }); @@ -309,9 +319,11 @@ describe('MaestroA2aExecutor', () => { }); }); - // Fix 2: execute() が内部エラーを throw → failed status-update + finished()(ストリームハングなし) - describe('execute — internal error (Fix 2)', () => { - it('createLocalTask が throw → failed status-update が publish され finished() が呼ばれる', async () => { + // Fix 2: execute() が内部エラーを throw → failed Task + finished()(ストリームハングなし) + // Task 4: createLocalTask throws before initialTask is published → outer catch fires with + // initialTaskPublished=false → publishTerminal emits kind:'task' (not status-update) + describe('execute — internal error (Fix 2 + Task 4)', () => { + it('createLocalTask が throw → failed Task (kind:task) が publish され finished() が呼ばれる', async () => { const baseRepo = makeFakeRepo(); const repo = { ...baseRepo, @@ -325,9 +337,9 @@ describe('MaestroA2aExecutor', () => { // SSE ストリームがハングしないこと expect(eventBus.finishedCalled).toBe(true); - // generic failed event が publish されること(内部詳細を漏らさない) + // Task 4: pre-initial-Task throw → full Task object so ResultManager can save it const failedEvent = eventBus.published.find( - (e: any) => e.kind === 'status-update' && e.status?.state === 'failed' && e.final === true, + (e: any) => e.kind === 'task' && e.status?.state === 'failed', ); expect(failedEvent).toBeDefined(); // 内部エラーの詳細("DB connection failed")はクライアントに漏れない @@ -524,4 +536,161 @@ describe('MaestroA2aExecutor', () => { expect(repo.requestJobCancel).not.toHaveBeenCalled(); }); }); + + // ── Task 5: ガバナンスガード ───────────────────────────────────────────────── + describe('execute — governance guards (Task 5)', () => { + const NOW_ISO = '2026-07-07T00:00:00.000Z'; + const nowFn = () => NOW_ISO; + + it('payload guard: oversized message → rejected Task (kind:task), createLocalTask not called', async () => { + const repo = makeFakeRepo(); + const eventBus = makeFakeEventBus(); + // maxPayloadBytes: 1 なので userMessage を JSON 化した時点で確実にオーバー + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 1 }, () => 0); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL, messageText: 'hello' }); + + await executor.execute(ctx as any, eventBus as any); + + expect(eventBus.finishedCalled).toBe(true); + // kind:'task' (pre-initial-Task path) with state='rejected' + const rejected = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'rejected', + ); + expect(rejected).toBeDefined(); + // No job row created + expect(repo.createLocalTask).not.toHaveBeenCalled(); + }); + + it('rate guard: rate bucket drained → rejected Task (kind:task), createLocalTask not called', async () => { + const repo = makeFakeRepo(); + const eventBus = makeFakeEventBus(); + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, () => 0); + // Pre-drain: consume the 1 available token so the next call is rejected + limiter.tryConsumeRate('grant-1'); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL }); + + await executor.execute(ctx as any, eventBus as any); + + expect(eventBus.finishedCalled).toBe(true); + const rejected = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'rejected', + ); + expect(rejected).toBeDefined(); + expect(repo.createLocalTask).not.toHaveBeenCalled(); + }); + + it('concurrency guard: db count at cap → rejected Task (kind:task), createLocalTask not called', async () => { + const repo = makeFakeRepo(); + // Seed the mock so countNonTerminalA2aTasksByGrant returns at-cap value + (repo.countNonTerminalA2aTasksByGrant as any).mockReturnValue(5); + const eventBus = makeFakeEventBus(); + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 5 }, () => 0); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL }); + + await executor.execute(ctx as any, eventBus as any); + + expect(eventBus.finishedCalled).toBe(true); + const rejected = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'rejected', + ); + expect(rejected).toBeDefined(); + expect(repo.createLocalTask).not.toHaveBeenCalled(); + }); + + it('skill budget guard: budget exhausted → rejected Task (kind:task), createLocalTask not called', async () => { + const repo = makeFakeRepo(); + const eventBus = makeFakeEventBus(); + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1 }, () => 0); + // Pre-drain: exhaust the 1 available skill budget slot + limiter.tryConsumeSkillBudget('grant-1'); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL }); + + await executor.execute(ctx as any, eventBus as any); + + expect(eventBus.finishedCalled).toBe(true); + const rejected = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'rejected', + ); + expect(rejected).toBeDefined(); + // Guard fires before createLocalTask — no DB row is created and no cancel is needed + expect(repo.createLocalTask).not.toHaveBeenCalled(); + expect(repo.cancelA2aLinkedJob).not.toHaveBeenCalled(); + }); + + it('happy path with generous limiter → initial submitted Task published', async () => { + const repo = makeFakeRepo(); + const eventBus = makeFakeEventBus(); + // All default limits are generous; task should proceed normally + const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS }, () => Date.now()); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL }); + + await executor.execute(ctx as any, eventBus as any); + + expect(eventBus.finishedCalled).toBe(true); + const submitted = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'submitted', + ); + expect(submitted).toBeDefined(); + }); + + it('reservation release: skill_budget reject after reservation → slot freed, no leak', async () => { + const repo = makeFakeRepo(); + const eventBus = makeFakeEventBus(); + const limiter = new A2aLimiter( + { ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1, maxConcurrentTasks: 2 }, + () => 0, + ); + // Pre-drain skill budget only, leaving concurrency intact + limiter.tryConsumeSkillBudget('grant-1'); + const executor = new MaestroA2aExecutor(repo as any, { + pollMs: 0, + sleep: async () => {}, + now: nowFn, + limiter, + }); + const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL }); + + // Execute → concurrency reserved, then skill_budget reject, then finally releases concurrency + await executor.execute(ctx as any, eventBus as any); + + const rejected = eventBus.published.find( + (e: any) => e.kind === 'task' && e.status?.state === 'rejected', + ); + expect(rejected).toBeDefined(); + + // After the exit, the reservation must be freed — new reservation at dbCount=0 should succeed + expect(limiter.tryReserveConcurrency('grant-1', 0)).toBe(true); + limiter.releaseConcurrency('grant-1'); // cleanup + }); + }); }); diff --git a/src/bridge/a2a/executor.ts b/src/bridge/a2a/executor.ts index a6e2cab..39ad80d 100644 --- a/src/bridge/a2a/executor.ts +++ b/src/bridge/a2a/executor.ts @@ -15,6 +15,7 @@ import type { A2aPrincipal } from './token-auth.js'; import { selectPieceForMessage } from './skill-router.js'; import { logger } from '../../logger.js'; import { enumerateOutputArtifacts, finalizeStatusFromJob, TERMINAL_JOB_STATUSES } from './task-finalize.js'; +import { A2aLimiter } from './limiter.js'; export interface MaestroA2aExecutorDeps { /** ポーリング間隔 (ms)。デフォルト 1000。テストでは 0 を注入。 */ @@ -25,6 +26,8 @@ export interface MaestroA2aExecutorDeps { now?: () => string; /** ポーリング上限イテレーション数。デフォルト 600(1s×600 = 10 分)。 */ maxPollIterations?: number; + /** ガバナンスリミッター(Task 5)。省略時はガバナンスチェックをスキップ。 */ + limiter?: A2aLimiter; } // TERMINAL_STATUSES は task-finalize.ts の TERMINAL_JOB_STATUSES を使用(ドリフト防止) @@ -35,6 +38,7 @@ export class MaestroA2aExecutor implements AgentExecutor { private readonly sleep: (ms: number) => Promise; private readonly now: () => string; private readonly maxPollIterations: number; + private readonly limiter?: A2aLimiter; /** A2A taskId → Maestro jobId のマッピング(cancelTask に使用)。 */ private readonly jobIdByA2aTaskId = new Map(); @@ -48,7 +52,14 @@ export class MaestroA2aExecutor implements AgentExecutor { this.pollMs = deps.pollMs ?? 1000; this.sleep = deps.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms))); this.now = deps.now ?? (() => new Date().toISOString()); - this.maxPollIterations = deps.maxPollIterations ?? 600; + this.limiter = deps.limiter; + if (deps.maxPollIterations !== undefined) { + this.maxPollIterations = deps.maxPollIterations; + } else if (deps.limiter && this.pollMs > 0) { + this.maxPollIterations = Math.max(1, Math.ceil(deps.limiter.limits.maxStreamSeconds * 1000 / this.pollMs)); + } else { + this.maxPollIterations = 600; + } } // ── helpers ────────────────────────────────────────────────────────────── @@ -110,6 +121,54 @@ export class MaestroA2aExecutor implements AgentExecutor { eventBus.publish(event); } + /** + * Unified terminal publisher (Task 4). + * + * When the initial Task event has NOT yet been published, the SDK's ResultManager + * has no entry for this taskId, so a bare status-update would be silently dropped. + * In that case we emit a full Task object (`kind:'task'`) which ResultManager saves + * and returns to the blocking caller. Once the initial Task is in the store + * (`initialPublished === true`) we fall back to a status-update (`final: true`), + * the same shape that publishFailedFinal / publishCanceledFinal have always produced. + */ + private publishTerminal( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + state: 'failed' | 'rejected' | 'canceled', + reason: string, + initialPublished: boolean, + metadata?: Record, + ): void { + if (!initialPublished) { + const task: Task = { + id: taskId, + contextId, + kind: 'task', + status: { + state, + message: this.makeStatusMessage(reason, contextId, taskId), + timestamp: this.now(), + }, + ...(metadata ? { metadata } : {}), + }; + eventBus.publish(task); // ResultManager saves + returns this terminal Task + return; + } + const ev: TaskStatusUpdateEvent = { + kind: 'status-update', + taskId, + contextId, + status: { + state, + message: this.makeStatusMessage(reason, contextId, taskId), + timestamp: this.now(), + } as TaskStatusUpdateEvent['status'], + final: true, + }; + eventBus.publish(ev); + } + // ── AgentExecutor.execute ───────────────────────────────────────────────── execute = async (requestContext: RequestContext, eventBus: ExecutionEventBus): Promise => { @@ -117,6 +176,12 @@ export class MaestroA2aExecutor implements AgentExecutor { // Fix 2: guard finished() so it is called exactly once regardless of path let finishedCalled = false; + // Task 4: track whether the initial Task event has been published so publishTerminal + // knows whether to emit a full Task object (required by SDK ResultManager for pre-initial + // denial paths) or a bare status-update (valid once the Task is in the store). + let initialTaskPublished = false; + // Task 5: concurrency reservation — must be declared above try so finally can release it. + let reservedGrant: string | null = null; const safeFinished = () => { if (!finishedCalled) { finishedCalled = true; @@ -130,15 +195,31 @@ export class MaestroA2aExecutor implements AgentExecutor { ?.a2aPrincipal as A2aPrincipal | undefined; if (!principal) { - this.publishFailedFinal(eventBus, taskId, contextId, 'unauthorized: no A2A principal'); + this.publishTerminal(eventBus, taskId, contextId, 'failed', 'unauthorized: no A2A principal', initialTaskPublished); safeFinished(); return; } + // 1b. ガバナンス: payload サイズ + レートリミット(scope 算出前に弾く = flood に scope DB 処理させない) + if (this.limiter) { + const L = this.limiter.limits; + const bytes = Buffer.byteLength(JSON.stringify(requestContext.userMessage ?? {}), 'utf8'); + if (bytes > L.maxPayloadBytes) { + await this.rejectGoverned(eventBus, taskId, contextId, principal, `payload too large: ${bytes} > ${L.maxPayloadBytes}`, 'payload', initialTaskPublished); + safeFinished(); + return; + } + if (!this.limiter.tryConsumeRate(principal.grantId)) { + await this.rejectGoverned(eventBus, taskId, contextId, principal, `rate limit exceeded (${L.ratePerMinute}/min)`, 'rate', initialTaskPublished); + safeFinished(); + return; + } + } + // 2. acting user の実際の可視スコープを算出(IDOR 防止・fail-closed) const scope = await computeEffectiveScope(this.repo, principal); if (!scope) { - this.publishFailedFinal(eventBus, taskId, contextId, 'unauthorized: unknown or inactive acting user'); + this.publishTerminal(eventBus, taskId, contextId, 'failed', 'unauthorized: unknown or inactive acting user', initialTaskPublished); safeFinished(); return; } @@ -159,7 +240,26 @@ export class MaestroA2aExecutor implements AgentExecutor { requestedSkillId, }); if (!sel) { - this.publishFailedFinal(eventBus, taskId, contextId, 'out of scope: no matching skill or space'); + this.publishTerminal(eventBus, taskId, contextId, 'failed', 'out of scope: no matching skill or space', initialTaskPublished); + safeFinished(); + return; + } + + // 4b. ガバナンス: 同時実行数チェック(createLocalTask 前に予約 — DB 行作成でカウントが増える前に弾く) + if (this.limiter) { + const dbCount = this.repo.countNonTerminalA2aTasksByGrant(principal.grantId); + if (!this.limiter.tryReserveConcurrency(principal.grantId, dbCount)) { + await this.rejectGoverned(eventBus, taskId, contextId, principal, `concurrency limit reached (${this.limiter.limits.maxConcurrentTasks})`, 'concurrency', initialTaskPublished); + safeFinished(); + return; + } + reservedGrant = principal.grantId; + } + + // 4c. ガバナンス: スキルバジェットチェック(createLocalTask 前 — 拒否時に local_tasks/jobs 行を作らない) + // concurrency 予約済みなので denial 時は finally ブロックが slot を解放する。 + if (this.limiter && !this.limiter.tryConsumeSkillBudget(principal.grantId)) { + await this.rejectGoverned(eventBus, taskId, contextId, principal, `hourly skill budget exhausted (${this.limiter.limits.skillBudgetPerHour}/h)`, 'skill_budget', initialTaskPublished); safeFinished(); return; } @@ -195,7 +295,7 @@ export class MaestroA2aExecutor implements AgentExecutor { localTask.id, ); if (!job) { - this.publishFailedFinal(eventBus, taskId, contextId, 'internal error: job not created'); + this.publishTerminal(eventBus, taskId, contextId, 'failed', 'internal error: job not created', initialTaskPublished); safeFinished(); return; } @@ -236,6 +336,7 @@ export class MaestroA2aExecutor implements AgentExecutor { }, }; eventBus.publish(initialTask); + initialTaskPublished = true; // 8. ポーリングループ let lastStatus: string | null = null; @@ -294,14 +395,18 @@ export class MaestroA2aExecutor implements AgentExecutor { safeFinished(); } catch (err) { // Fix 2: catch unhandled DB / internal errors so the SSE stream never hangs + // Task 4: use publishTerminal so an early throw (before initial Task) still produces a + // valid Task object that SDK ResultManager can save and return to the caller. logger.warn(`[a2a] executor error: ${err}`); if (!finishedCalled) { - this.publishFailedFinal(eventBus, taskId, contextId, 'internal error'); + this.publishTerminal(eventBus, taskId, contextId, 'failed', 'internal error', initialTaskPublished); } } finally { // T6: per-task map cleanup — 全終了経路(terminal/timeout/throw)で確実に実行 this.jobIdByA2aTaskId.delete(taskId); this.contextIdByA2aTaskId.delete(taskId); + // Task 5: concurrency reservation release — every exit path (terminal/timeout/throw/reject-after-reserve) + if (reservedGrant && this.limiter) this.limiter.releaseConcurrency(reservedGrant); // Fix 2: guarantee finished() is called exactly once on every exit path safeFinished(); } @@ -373,6 +478,34 @@ export class MaestroA2aExecutor implements AgentExecutor { } } + // ── governance helper ───────────────────────────────────────────────────── + + /** + * Publishes a terminal `rejected` event and records a best-effort audit log entry. + * Called by the governance guards (payload / rate / concurrency / skill_budget). + */ + private async rejectGoverned( + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + principal: A2aPrincipal, + reason: string, + limit: string, + initialPublished: boolean, + ): Promise { + logger.info(`[a2a-exec] governance reject limit=${limit} grant=${principal.grantId}`); + this.publishTerminal(eventBus, taskId, contextId, 'rejected', reason, initialPublished); + try { + await this.repo.addAuditLog(null, 'a2a_governance_reject', 'a2a', { + limit, + reason, + grantId: principal.grantId, + clientId: principal.clientId, + actingUserId: principal.actingUserId, + }); + } catch { /* best-effort audit */ } + } + // ── AgentExecutor.cancelTask ────────────────────────────────────────────── cancelTask = async (taskId: string, eventBus: ExecutionEventBus): Promise => { diff --git a/src/bridge/a2a/limiter.test.ts b/src/bridge/a2a/limiter.test.ts new file mode 100644 index 0000000..21f7ce7 --- /dev/null +++ b/src/bridge/a2a/limiter.test.ts @@ -0,0 +1,328 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { A2aLimiter, DEFAULT_A2A_LIMITS, resolveA2aLimits } from './limiter.js'; + +function clock(start: number) { + let t = start; + return { now: () => t, advance: (ms: number) => { t += ms; } }; +} + +// --------------------------------------------------------------------------- +// resolveA2aLimits +// --------------------------------------------------------------------------- +describe('resolveA2aLimits (strict integer≥1)', () => { + it('fills defaults for missing/invalid (brief canonical test)', () => { + const r = resolveA2aLimits({ + ratePerMinute: 7, + maxPayloadBytes: 0, + skillBudgetPerHour: 0.5, + maxConcurrentTasks: '3' as any, + maxStreamSeconds: NaN, + }); + expect(r.ratePerMinute).toBe(7); // valid integer≥1 → accepted + expect(r.maxPayloadBytes).toBe(DEFAULT_A2A_LIMITS.maxPayloadBytes); // 0 rejected + expect(r.skillBudgetPerHour).toBe(DEFAULT_A2A_LIMITS.skillBudgetPerHour); // 0.5 rejected + expect(r.maxConcurrentTasks).toBe(DEFAULT_A2A_LIMITS.maxConcurrentTasks); // "3" rejected + expect(r.maxStreamSeconds).toBe(DEFAULT_A2A_LIMITS.maxStreamSeconds); // NaN rejected + }); + + it('rejects 0 → default', () => { + const r = resolveA2aLimits({ ratePerMinute: 0 }); + expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute); + }); + + it('rejects negative → default', () => { + const r = resolveA2aLimits({ ratePerMinute: -5 }); + expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute); + }); + + it('rejects NaN → default', () => { + const r = resolveA2aLimits({ maxStreamSeconds: NaN }); + expect(r.maxStreamSeconds).toBe(DEFAULT_A2A_LIMITS.maxStreamSeconds); + }); + + it('rejects non-integer 0.5 → default (no starvation)', () => { + const r = resolveA2aLimits({ ratePerMinute: 0.5 }); + expect(r.ratePerMinute).toBe(DEFAULT_A2A_LIMITS.ratePerMinute); + }); + + it('rejects string "3" → default', () => { + const r = resolveA2aLimits({ maxConcurrentTasks: '3' as any }); + expect(r.maxConcurrentTasks).toBe(DEFAULT_A2A_LIMITS.maxConcurrentTasks); + }); + + it('accepts all valid integer≥1 values', () => { + const r = resolveA2aLimits({ + ratePerMinute: 10, + maxConcurrentTasks: 3, + maxPayloadBytes: 1024, + maxStreamSeconds: 60, + skillBudgetPerHour: 50, + maxConcurrentResubscribe: 2, + }); + expect(r.ratePerMinute).toBe(10); + expect(r.maxConcurrentTasks).toBe(3); + expect(r.maxPayloadBytes).toBe(1024); + expect(r.maxStreamSeconds).toBe(60); + expect(r.skillBudgetPerHour).toBe(50); + expect(r.maxConcurrentResubscribe).toBe(2); + }); + + it('returns defaults for undefined input', () => { + const r = resolveA2aLimits(); + expect(r).toEqual(DEFAULT_A2A_LIMITS); + }); + + it('returns defaults for empty object', () => { + const r = resolveA2aLimits({}); + expect(r).toEqual(DEFAULT_A2A_LIMITS); + }); + + it('DEFAULT_A2A_LIMITS has expected values', () => { + expect(DEFAULT_A2A_LIMITS.ratePerMinute).toBe(30); + expect(DEFAULT_A2A_LIMITS.maxConcurrentTasks).toBe(5); + expect(DEFAULT_A2A_LIMITS.maxPayloadBytes).toBe(65536); + expect(DEFAULT_A2A_LIMITS.maxStreamSeconds).toBe(600); + expect(DEFAULT_A2A_LIMITS.skillBudgetPerHour).toBe(100); + expect(DEFAULT_A2A_LIMITS.maxConcurrentResubscribe).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// tryConsumeRate (token bucket) +// --------------------------------------------------------------------------- +describe('tryConsumeRate', () => { + it('allows burst up to ratePerMinute then returns false', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 3 }, c.now); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(false); + }); + + it('refills over time at ratePerMinute per minute', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(false); // exhausted + + c.advance(30_000); // +30s → +1 token + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(false); // exhausted again + + c.advance(60_000); // +60s → +2 tokens, capped at 2 + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(false); + }); + + it('guards negative elapsed (no backward-clock token loss)', () => { + const c = clock(1000); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, c.now); + lim.tryConsumeRate('g'); // consume 1 → 0 tokens + // simulate slight clock jitter (elapsed < 0 clamped to 0) + c.advance(-500); + expect(lim.tryConsumeRate('g')).toBe(false); // no artificial tokens added + }); + + it('grants are isolated: separate buckets per grantId', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, c.now); + expect(lim.tryConsumeRate('g1')).toBe(true); + expect(lim.tryConsumeRate('g1')).toBe(false); + expect(lim.tryConsumeRate('g2')).toBe(true); // separate bucket + expect(lim.tryConsumeRate('g2')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// tryConsumeSkillBudget (rolling-hour window) +// --------------------------------------------------------------------------- +describe('tryConsumeSkillBudget', () => { + it('allows up to skillBudgetPerHour then returns false', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 3 }, c.now); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(false); + }); + + it('frees budget after 1h rolling window', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 2 }, c.now); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(false); // exhausted + + c.advance(3_600_001); // 1h+1ms: all timestamps expire + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(false); + }); + + it('partial window-free: only expired entries pruned', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 3 }, c.now); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); // t=0 + expect(lim.tryConsumeSkillBudget('g')).toBe(true); // t=0 + c.advance(3_600_001); + // The two t=0 timestamps are now expired; can consume 3 more + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(true); + expect(lim.tryConsumeSkillBudget('g')).toBe(false); + }); + + it('grants are isolated', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 1 }, c.now); + expect(lim.tryConsumeSkillBudget('g1')).toBe(true); + expect(lim.tryConsumeSkillBudget('g1')).toBe(false); + expect(lim.tryConsumeSkillBudget('g2')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// tryReserveConcurrency / releaseConcurrency +// --------------------------------------------------------------------------- +describe('tryReserveConcurrency', () => { + it('caps synchronously and respects the DB backstop (brief canonical test)', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0); + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g', 0)).toBe(false); // 2 reserved + lim.releaseConcurrency('g'); + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); // freed one + + const lim2 = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0); + expect(lim2.tryReserveConcurrency('g', 2)).toBe(false); // DB already at cap + }); + + it('max(dbCount, reserved) semantics: dbCount=1 + reserved=1 → effective 1, second reserve succeeds', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 2 }, () => 0); + expect(lim.tryReserveConcurrency('g', 1)).toBe(true); // max(1,0)=1 < 2 → ok, reserved=1 + expect(lim.tryReserveConcurrency('g', 1)).toBe(true); // max(1,1)=1 < 2 → ok, reserved=2 + expect(lim.tryReserveConcurrency('g', 1)).toBe(false); // max(1,2)=2 >= 2 → fail + }); + + it('dbCount alone triggers the cap (DB backstop without in-memory)', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 3 }, () => 0); + expect(lim.tryReserveConcurrency('g', 3)).toBe(false); // max(3,0)=3 >= 3 + expect(lim.tryReserveConcurrency('g', 2)).toBe(true); // max(2,0)=2 < 3 → ok + }); + + it('releaseConcurrency decrements and floors at 0', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 3 }, () => 0); + lim.tryReserveConcurrency('g', 0); // reserved=1 + lim.tryReserveConcurrency('g', 0); // reserved=2 + lim.releaseConcurrency('g'); // reserved=1 + lim.releaseConcurrency('g'); // reserved=0 → delete + lim.releaseConcurrency('g'); // noop (already 0) + // After over-release, should still be able to reserve normally + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g', 0)).toBe(false); + }); + + it('per-grant isolation for concurrency', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentTasks: 1 }, () => 0); + expect(lim.tryReserveConcurrency('g1', 0)).toBe(true); + expect(lim.tryReserveConcurrency('g1', 0)).toBe(false); + expect(lim.tryReserveConcurrency('g2', 0)).toBe(true); // separate counter + }); +}); + +// --------------------------------------------------------------------------- +// tryAcquireResubscribe / releaseResubscribe +// --------------------------------------------------------------------------- +describe('tryAcquireResubscribe / releaseResubscribe', () => { + it('caps at maxConcurrentResubscribe then returns false', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 2 }, () => 0); + expect(lim.tryAcquireResubscribe('g')).toBe(true); + expect(lim.tryAcquireResubscribe('g')).toBe(true); + expect(lim.tryAcquireResubscribe('g')).toBe(false); + }); + + it('releaseResubscribe frees a slot', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0); + expect(lim.tryAcquireResubscribe('g')).toBe(true); + expect(lim.tryAcquireResubscribe('g')).toBe(false); + lim.releaseResubscribe('g'); + expect(lim.tryAcquireResubscribe('g')).toBe(true); + }); + + it('per-grant isolation', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0); + expect(lim.tryAcquireResubscribe('g1')).toBe(true); + expect(lim.tryAcquireResubscribe('g1')).toBe(false); + expect(lim.tryAcquireResubscribe('g2')).toBe(true); // separate counter + }); + + it('over-release floors at 0 and does not corrupt subsequent acquires', () => { + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxConcurrentResubscribe: 1 }, () => 0); + lim.tryAcquireResubscribe('g'); + lim.releaseResubscribe('g'); + lim.releaseResubscribe('g'); // noop + expect(lim.tryAcquireResubscribe('g')).toBe(true); + expect(lim.tryAcquireResubscribe('g')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Opportunistic eviction +// --------------------------------------------------------------------------- +describe('eviction', () => { + it('evicts a stale full rate bucket after >1h idle (Map size shrinks)', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now); + + // Populate two grant buckets + lim.tryConsumeRate('g1'); + lim.tryConsumeRate('g2'); + expect(lim.rateBucketSize()).toBe(2); + + // Advance >1h so both buckets become full and stale + c.advance(3_600_001); + + // Accessing g1 triggers eviction of its stale entry and re-creates it + const result = lim.tryConsumeRate('g1'); + expect(result).toBe(true); // fresh bucket → 1 token consumed + // g1 was evicted then re-created (size stays 2: g1 re-created, g2 still there) + // The key assertion: g1's bucket was deleted then re-added; g2 is still stale + expect(lim.rateBucketSize()).toBe(2); + }); + + it('evicted stale bucket starts with fresh capacity', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 2 }, c.now); + // Exhaust bucket + lim.tryConsumeRate('g'); + lim.tryConsumeRate('g'); + expect(lim.tryConsumeRate('g')).toBe(false); + + // Advance >1h → bucket refills to capacity (2) and becomes stale → eviction + c.advance(3_600_001); + // Both tokens should be available (fresh bucket) + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(true); + expect(lim.tryConsumeRate('g')).toBe(false); + }); + + it('evicts empty skillHits arrays (pruned to empty on successful consume)', () => { + const c = clock(0); + const lim = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, skillBudgetPerHour: 2 }, c.now); + lim.tryConsumeSkillBudget('g'); + expect(lim.skillHitsSize()).toBe(1); + + // Advance >1h so the hit expires; new consume should work + c.advance(3_600_001); + const result = lim.tryConsumeSkillBudget('g'); + expect(result).toBe(true); + // Array had 0 elements after prune + 1 new added → size=1 + expect(lim.skillHitsSize()).toBe(1); + }); +}); diff --git a/src/bridge/a2a/limiter.ts b/src/bridge/a2a/limiter.ts new file mode 100644 index 0000000..7a87a72 --- /dev/null +++ b/src/bridge/a2a/limiter.ts @@ -0,0 +1,216 @@ +/** + * A2aLimiter: process-local rate / budget / concurrency counters for A2A grant enforcement. + * + * IMPORTANT — process-local, resets on restart: + * All counters live in-process memory and are lost when the server restarts. + * This is intentional and acceptable because: + * - Rate / skill-budget counters: brief gaps from restarts are fail-open by design. + * - Concurrency reservation: the durable backstop is the `dbCount` argument passed to + * tryReserveConcurrency(). The in-memory counter is an additional synchronous guard + * against TOCTOU races within a single process instance. + * + * Keyed by grantId (UUID from the a2a_delegations table). + */ + +import { logger } from '../../logger.js'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface A2aLimits { + ratePerMinute: number; + maxConcurrentTasks: number; + maxPayloadBytes: number; + maxStreamSeconds: number; + skillBudgetPerHour: number; + maxConcurrentResubscribe: number; +} + +export const DEFAULT_A2A_LIMITS: A2aLimits = { + ratePerMinute: 30, + maxConcurrentTasks: 5, + maxPayloadBytes: 65536, + maxStreamSeconds: 600, + skillBudgetPerHour: 100, + maxConcurrentResubscribe: 5, +}; + +// --------------------------------------------------------------------------- +// resolveA2aLimits — strict integer≥1 validation +// --------------------------------------------------------------------------- + +function posInt(v: unknown, fallback: number): number { + return typeof v === 'number' && Number.isInteger(v) && v >= 1 ? v : fallback; +} + +export function resolveA2aLimits(p?: Partial>): A2aLimits { + const src = p ?? {}; + const out = {} as A2aLimits; + for (const k of Object.keys(DEFAULT_A2A_LIMITS) as (keyof A2aLimits)[]) { + const resolved = posInt(src[k], DEFAULT_A2A_LIMITS[k]); + if (src[k] !== undefined && resolved !== src[k]) { + logger.warn(`[a2a] invalid limit ${k}=${String(src[k])}, using default ${resolved}`); + } + out[k] = resolved; + } + return out; +} + +// --------------------------------------------------------------------------- +// Internal state shapes +// --------------------------------------------------------------------------- + +interface RateBucket { + tokens: number; + lastRefillMs: number; +} + +const ONE_HOUR_MS = 3_600_000; + +// --------------------------------------------------------------------------- +// A2aLimiter +// --------------------------------------------------------------------------- + +export class A2aLimiter { + readonly limits: A2aLimits; + private readonly _now: () => number; + + /** Token buckets for rate limiting: grantId → { tokens, lastRefillMs } */ + private readonly _rateBuckets = new Map(); + /** Rolling-hour timestamps for skill budget: grantId → timestamp[] */ + private readonly _skillHits = new Map(); + /** In-memory concurrency reservations: grantId → count */ + private readonly _reservations = new Map(); + /** Resubscribe slot counters: grantId → count */ + private readonly _resubscribes = new Map(); + + constructor(limits: A2aLimits, now: () => number = () => Date.now()) { + this.limits = limits; + this._now = now; + } + + // ------------------------------------------------------------------------- + // Rate limiting (token bucket) + // ------------------------------------------------------------------------- + + tryConsumeRate(grantId: string): boolean { + const nowMs = this._now(); + const capacity = this.limits.ratePerMinute; + let bucket = this._rateBuckets.get(grantId); + + if (bucket) { + const elapsed = Math.max(0, nowMs - bucket.lastRefillMs); + bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * capacity / 60_000); + + // Opportunistic eviction: full bucket idle for >1h → drop stale entry. + // The next access will start fresh (same observable effect as a full refill). + if (bucket.tokens >= capacity && elapsed >= ONE_HOUR_MS) { + this._rateBuckets.delete(grantId); + bucket = undefined; + } else { + bucket.lastRefillMs = nowMs; + } + } + + if (!bucket) { + bucket = { tokens: capacity, lastRefillMs: nowMs }; + this._rateBuckets.set(grantId, bucket); + } + + if (bucket.tokens >= 1) { + bucket.tokens -= 1; + return true; + } + return false; + } + + // ------------------------------------------------------------------------- + // Skill budget (rolling-hour window) + // ------------------------------------------------------------------------- + + tryConsumeSkillBudget(grantId: string): boolean { + const nowMs = this._now(); + const cutoff = nowMs - ONE_HOUR_MS; + const existing = this._skillHits.get(grantId) ?? []; + + // Prune timestamps older than 1h (keep only recent ones) + const hits = existing.filter(ts => ts > cutoff); + + if (hits.length < this.limits.skillBudgetPerHour) { + hits.push(nowMs); + this._skillHits.set(grantId, hits); + return true; + } + + // Budget exhausted. Update the pruned array; evict if empty (defensive). + if (hits.length > 0) { + this._skillHits.set(grantId, hits); + } else { + this._skillHits.delete(grantId); + } + return false; + } + + // ------------------------------------------------------------------------- + // Concurrency reservation (synchronous TOCTOU guard) + // ------------------------------------------------------------------------- + + /** + * Try to reserve one concurrency slot for grantId. + * @param dbCount Current running-job count from the DB (durable backstop). + * Returns true and increments the in-memory counter if + * Math.max(dbCount, reservedCount) < maxConcurrentTasks. + * This is a plain synchronous method — no awaits. + */ + tryReserveConcurrency(grantId: string, dbCount: number): boolean { + const reserved = this._reservations.get(grantId) ?? 0; + if (Math.max(dbCount, reserved) >= this.limits.maxConcurrentTasks) return false; + this._reservations.set(grantId, reserved + 1); + return true; + } + + /** Decrement the concurrency reservation for grantId, flooring at 0. */ + releaseConcurrency(grantId: string): void { + const n = this._reservations.get(grantId) ?? 0; + if (n <= 1) { + this._reservations.delete(grantId); + } else { + this._reservations.set(grantId, n - 1); + } + } + + // ------------------------------------------------------------------------- + // Resubscribe slot tracking + // ------------------------------------------------------------------------- + + tryAcquireResubscribe(grantId: string): boolean { + const current = this._resubscribes.get(grantId) ?? 0; + if (current >= this.limits.maxConcurrentResubscribe) return false; + this._resubscribes.set(grantId, current + 1); + return true; + } + + releaseResubscribe(grantId: string): void { + const n = this._resubscribes.get(grantId) ?? 0; + if (n <= 1) { + this._resubscribes.delete(grantId); + } else { + this._resubscribes.set(grantId, n - 1); + } + } + + // ------------------------------------------------------------------------- + // Test hooks (Map size inspection for eviction tests) + // ------------------------------------------------------------------------- + + /** Number of live rate-bucket entries (test hook). */ + rateBucketSize(): number { + return this._rateBuckets.size; + } + + /** Number of live skill-hits entries (test hook). */ + skillHitsSize(): number { + return this._skillHits.size; + } +} diff --git a/src/bridge/a2a/request-handler-auth-gate.test.ts b/src/bridge/a2a/request-handler-auth-gate.test.ts index da67fef..ca68495 100644 --- a/src/bridge/a2a/request-handler-auth-gate.test.ts +++ b/src/bridge/a2a/request-handler-auth-gate.test.ts @@ -14,11 +14,12 @@ * and a stub executor (fastest, no HTTP round-trip needed for unit assertions). * One E2E supertest case covers the full HTTP path. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import express from 'express'; import request from 'supertest'; import { A2AError, + DefaultRequestHandler, InMemoryTaskStore, ServerCallContext, UnauthenticatedUser, @@ -28,6 +29,7 @@ import { buildBaseCard } from './agent-card.js'; import { AuthGatedRequestHandler, mountA2aRequestHandler } from './request-handler.js'; import { A2aAuthenticatedUser } from './user-builder.js'; import type { A2aPrincipal } from './token-auth.js'; +import { A2aLimiter, resolveA2aLimits } from './limiter.js'; // ─── fixtures ───────────────────────────────────────────────────────────────── @@ -134,6 +136,223 @@ describe('AuthGatedRequestHandler.resubscribe', () => { }); }); +// ─── Rate limiting + resubscribe slot cap (Task 7) ──────────────────────────── + +describe('AuthGatedRequestHandler — rate limiting', () => { + function makeHandlerWithLimiter(limiter: A2aLimiter) { + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter); + } + + it('getTask: second call past rate=1 throws A2AError (rate limit exceeded)', async () => { + const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 })); + const h = makeHandlerWithLimiter(limiter); + // First call: auth OK, 1 rate token consumed → falls through to taskNotFound (not rate error) + await h.getTask({ id: 'x' }, AUTH_CTX).catch(() => {}); + // Second call: no token left → rate-limit error + const err = await h.getTask({ id: 'x' }, AUTH_CTX).catch(e => e); + expect(err).toBeInstanceOf(A2AError); + expect(err.code).toBe(-32600); + expect(err.message).toMatch(/rate limit/); + }); + + it('getTask: unauthenticated throws auth error even when budget is fully exhausted', async () => { + const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 })); + // Pre-drain the bucket for grant g1 (the FAKE_PRINCIPAL's grantId) + limiter.tryConsumeRate('g1'); + const h = makeHandlerWithLimiter(limiter); + // Auth check must fire before rate check; message must be 'unauthorized', not 'rate limit' + const err = await h.getTask({ id: 'x' }, UNAUTH_CTX).catch(e => e); + expect(err).toBeInstanceOf(A2AError); + expect(err.message).toMatch(/unauthorized/); + }); + + it('cancelTask: second call past rate=1 throws A2AError (rate limit exceeded)', async () => { + const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 })); + const h = makeHandlerWithLimiter(limiter); + // First call: auth OK, 1 rate token consumed → falls through to taskNotFound (not rate error) + await h.cancelTask({ id: 'x' }, AUTH_CTX).catch(() => {}); + // Second call: no token left → rate-limit error + const err = await h.cancelTask({ id: 'x' }, AUTH_CTX).catch(e => e); + expect(err).toBeInstanceOf(A2AError); + expect(err.code).toBe(-32600); + expect(err.message).toMatch(/rate limit/); + }); +}); + +describe('AuthGatedRequestHandler — resubscribe slot cap (Task 7)', () => { + function makeHandlerWithLimiter(limiter: A2aLimiter) { + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter); + } + + it('resubscribe: second call throws when slot cap (maxConcurrentResubscribe=1) is reached', () => { + const limiter = new A2aLimiter(resolveA2aLimits({ maxConcurrentResubscribe: 1, ratePerMinute: 100 })); + const h = makeHandlerWithLimiter(limiter); + // First resubscribe: slot acquired + const gen1 = h.resubscribe({ id: 'x' }, AUTH_CTX); + expect(gen1).toBeDefined(); + // Second resubscribe: slot full → synchronous throw + expect(() => h.resubscribe({ id: 'x' }, AUTH_CTX)).toThrow(A2AError); + gen1.return?.(undefined); // clean up (async; we don't await) + }); + + it('resubscribe: slot released via finally when generator is iterated and exhausted', async () => { + const limiter = new A2aLimiter(resolveA2aLimits({ maxConcurrentResubscribe: 1, ratePerMinute: 100 })); + const h = makeHandlerWithLimiter(limiter); + // Acquire the slot + const gen1 = h.resubscribe({ id: 'x' }, AUTH_CTX); + // Start the generator — inner store will throw (task not found), triggering finally + await gen1.next().catch(() => {}); + // Slot should now be released; a new resubscribe must not throw + let gen2: AsyncGenerator | undefined; + expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow(); + expect(gen2).toBeDefined(); + await gen2!.next().catch(() => {}); // clean up + }); +}); + +// ─── maxStreamSeconds fires for a quiet stream (Task 7 review fix) ─────────── + +describe('AuthGatedRequestHandler — maxStreamSeconds wall-clock race', () => { + it('quiet stream (no events) completes and releases slot at deadline', async () => { + // Use a fractional maxStreamSeconds (0.05 = 50 ms) for a fast test. + // A2aLimiter accepts raw limits directly — bypassing resolveA2aLimits integer≥1 constraint + // is intentional here so we don't need to wait a full second. + const limiter = new A2aLimiter({ + ratePerMinute: 100, + maxConcurrentTasks: 5, + maxPayloadBytes: 65536, + maxStreamSeconds: 0.05, // 50 ms deadline + skillBudgetPerHour: 100, + maxConcurrentResubscribe: 1, + }); + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + const h = new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter); + + // Stub super.resubscribe with a generator that never emits events (quiet live stream). + // This is the exact scenario the timer-race fixes: the old for-await loop would + // hold the slot indefinitely; the new Promise.race releases it at the deadline. + const neverYielding: AsyncGenerator = { + [Symbol.asyncIterator]() { return this; }, + next: () => new Promise(() => {}), // blocks indefinitely + return: async (v: any) => ({ done: true as const, value: v }), + throw: async (e: any): Promise => { throw e; }, + }; + const spy = vi.spyOn(DefaultRequestHandler.prototype, 'resubscribe').mockReturnValue( + neverYielding as any, + ); + + try { + // Slot cap=1: acquire it + const gen = h.resubscribe({ id: 'x' }, AUTH_CTX); + // Kick the generator — suspends inside the 50 ms Promise.race + const firstNext = gen.next(); + // Wait 120 ms (real timers) — the 50 ms deadline fires, race resolves with 'timeout' + await new Promise(res => setTimeout(res, 120)); + // Generator must have completed via the timeout path + const result = await firstNext; + expect(result.done).toBe(true); + // Slot must be released — second resubscribe must not throw + let gen2: AsyncGenerator | undefined; + expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow(); + expect(gen2).toBeDefined(); + await gen2!.return?.(undefined); // cleanup + } finally { + spy.mockRestore(); + } + }, 2000); // generous timeout for the real-timer wait + + it('quiet REAL async generator: deadline still releases slot even though inner .return() never settles', async () => { + // Regression guard for the follow-up fix: a REAL async generator suspended inside its + // body (not at a yield) queues a .return() behind the still-pending .next(), so the + // return never settles for a quiet stream. The old `await it.return()` would hang the + // wrapper forever (never reaching finally → slot leak). The stub in the test above hid + // this because its hand-rolled `return` resolved immediately. Here we use a genuine + // async generator to reproduce the queuing semantics. + const limiter = new A2aLimiter({ + ratePerMinute: 100, + maxConcurrentTasks: 5, + maxPayloadBytes: 65536, + maxStreamSeconds: 0.05, // 50 ms deadline + skillBudgetPerHour: 100, + maxConcurrentResubscribe: 1, + }); + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + const h = new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter); + + // A real async generator that suspends forever inside its body. Calling .return() while + // the .next() promise is pending queues the return; it will not settle for this stream. + async function* realQuiet(): AsyncGenerator { + await new Promise(() => {}); // never resolves + yield 1; // unreachable + } + const spy = vi.spyOn(DefaultRequestHandler.prototype, 'resubscribe').mockReturnValue( + realQuiet() as any, + ); + + try { + const gen = h.resubscribe({ id: 'x' }, AUTH_CTX); + const firstNext = gen.next(); + await new Promise(res => setTimeout(res, 120)); // 50 ms deadline fires + // Must complete via the timeout path despite the inner return never settling. + const result = await firstNext; + expect(result.done).toBe(true); + // Slot released → a fresh resubscribe must not throw. + let gen2: AsyncGenerator | undefined; + expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow(); + await gen2!.return?.(undefined); + } finally { + spy.mockRestore(); + } + }, 2000); +}); + +// ─── fail-closed: authenticated but missing grantId is denied when metering is active ── + +describe('AuthGatedRequestHandler — fail-closed on missing grantId', () => { + const NO_GRANT_PRINCIPAL = { ...FAKE_PRINCIPAL, grantId: undefined } as any; + const NO_GRANT_CTX = new ServerCallContext(undefined, new A2aAuthenticatedUser(NO_GRANT_PRINCIPAL)); + + function makeHandlerWithLimiter() { + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 100, maxConcurrentResubscribe: 5 })); + return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter); + } + + it('getTask denies an authenticated caller with no grantId (limiter active)', async () => { + const h = makeHandlerWithLimiter(); + await expect(h.getTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32600 }); + }); + + it('cancelTask denies an authenticated caller with no grantId (limiter active)', async () => { + const h = makeHandlerWithLimiter(); + await expect(h.cancelTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32600 }); + }); + + it('resubscribe denies an authenticated caller with no grantId (limiter active)', () => { + const h = makeHandlerWithLimiter(); + expect(() => h.resubscribe({ id: 'x' }, NO_GRANT_CTX)).toThrow(A2AError); + }); + + it('without a limiter, a missing grantId is unmetered (reaches the store, not denied)', async () => { + // No limiter → metering off → must not fail-closed. Store throws taskNotFound (-32001), + // proving the auth+grant gate was passed rather than denied for the missing grant. + const h = makeHandler(); + await expect(h.getTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32001 }); + }); +}); + // ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ─────────── describe('mountA2aRequestHandler – tasks/get auth gate (E2E)', () => { diff --git a/src/bridge/a2a/request-handler.test.ts b/src/bridge/a2a/request-handler.test.ts index 8fc4c82..427c925 100644 --- a/src/bridge/a2a/request-handler.test.ts +++ b/src/bridge/a2a/request-handler.test.ts @@ -142,13 +142,54 @@ describe('mountA2aRequestHandler – smoke', () => { }, }); - // Handler must not 500-crash; SDK returns a JSON-RPC error for unauthenticated + // Handler must not 500-crash and must return a valid JSON-RPC 2.0 envelope. + // message/send is NOT gated by AuthGatedRequestHandler — the executor creates + // the task (returning a result), then fails it asynchronously. So the HTTP + // response carries either a JSON-RPC result or error, never a 500. expect(res.status).not.toBe(500); expect(res.headers['content-type']).toMatch(/json/); - // Assert JSON-RPC error envelope structure - expect(res.body).toHaveProperty('error'); - expect(res.body.error).toHaveProperty('code'); - expect(res.body.error).toHaveProperty('message'); expect(res.body.jsonrpc).toBe('2.0'); + expect('result' in res.body || 'error' in res.body).toBe(true); + }); +}); + +// ─── body size limit ────────────────────────────────────────────────────────── + +describe('mountA2aRequestHandler – body size limit', () => { + it('POST /a2a with body exceeding maxPayloadBytes returns HTTP 413', async () => { + const repo = new Repository(':memory:'); + seedRepo(repo); + + const app = express(); + // Mount with a tiny limit (50 bytes) so a normal JSON-RPC body exceeds it. + mountA2aRequestHandler(app, { + provider: fakeProvider(), + repo, + ...BASE_DEPS, + limits: { maxPayloadBytes: 50 }, + }); + + // This body is well over 50 bytes. + const bigBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'message/send', + params: { + message: { + messageId: 'm1', + role: 'user', + parts: [{ kind: 'text', text: 'hello world — this payload deliberately exceeds the tiny limit' }], + }, + }, + }); + + const res = await request(app) + .post('/a2a') + .set('Content-Type', 'application/json') + .send(bigBody); + + // express.json({ limit }) calls next(err) with a PayloadTooLargeError (status 413) + // before the SDK handler sees the body. Express's default error handler surfaces it. + expect(res.status).toBe(413); }); }); diff --git a/src/bridge/a2a/request-handler.ts b/src/bridge/a2a/request-handler.ts index d5fcd3d..bf999b5 100644 --- a/src/bridge/a2a/request-handler.ts +++ b/src/bridge/a2a/request-handler.ts @@ -7,6 +7,7 @@ * Plan 2A の自前 REST route と競合しない(path が異なる)。 * 両者は同じ `buildExtendedCard` + `computeEffectiveScope` を呼ぶため実装ドリフトは起きない。 */ +import express from 'express'; import type { Express } from 'express'; import { A2AError, DefaultRequestHandler, type ServerCallContext } from '@a2a-js/sdk/server'; import { jsonRpcHandler } from '@a2a-js/sdk/server/express'; @@ -19,6 +20,7 @@ import { buildBaseCard, buildExtendedCard } from './agent-card.js'; import { computeEffectiveScope } from './delegation.js'; import { makeA2aUserBuilder } from './user-builder.js'; import type { A2aPrincipal } from './token-auth.js'; +import { A2aLimiter, resolveA2aLimits, type A2aLimits } from './limiter.js'; export interface MountA2aRequestHandlerDeps { provider: Provider; @@ -26,6 +28,7 @@ export interface MountA2aRequestHandlerDeps { baseUrl: string; issuer: string; version: string; + limits?: Partial; } type CardDeps = { baseUrl: string; issuer: string; version: string }; @@ -41,15 +44,39 @@ type CardDeps = { baseUrl: string; issuer: string; version: string }; * * sendMessage / sendMessageStream はゲートしない:executor.execute() が * principal のチェックを行い、unauthorized ならタスクを failed 状態で終了させる(既存の保護)。 + * + * Task 7: limiter が注入された場合、認証チェックの後に rate-limit と + * resubscribe スロットキャップを適用する(auth → rate → slot の順序を保証)。 */ export class AuthGatedRequestHandler extends DefaultRequestHandler { + private limiter?: A2aLimiter; + + constructor( + agentCard: ConstructorParameters[0], + taskStore: ConstructorParameters[1], + executor: ConstructorParameters[2], + extendedCardProvider?: ConstructorParameters[6], + limiter?: A2aLimiter, + ) { + super(agentCard, taskStore, executor, undefined, undefined, undefined, extendedCardProvider); + this.limiter = limiter; + } + override async getTask(params: Parameters[0], context?: ServerCallContext) { this.requireAuthenticated(context); + const grantId = this.meteredGrantId(context); + if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) { + throw A2AError.invalidRequest('rate limit exceeded'); + } return super.getTask(params, context); } override async cancelTask(params: Parameters[0], context?: ServerCallContext) { this.requireAuthenticated(context); + const grantId = this.meteredGrantId(context); + if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) { + throw A2AError.invalidRequest('rate limit exceeded'); + } return super.cancelTask(params, context); } @@ -57,10 +84,97 @@ export class AuthGatedRequestHandler extends DefaultRequestHandler { * 認証ゲートを同期的に実行してから super.resubscribe() を返す。 * 同期 throw にすることで、JsonRpcTransportHandler がジェネレータを取得する前に * エラーを捕捉できる(SSE ヘッダ送信前に正規の JSON-RPC エラーレスポンスを返せる)。 + * + * limiter 注入時: + * 1. auth check (sync throw) + * 2. rate check (sync throw) + * 3. slot acquire (sync throw if cap reached) + * 4. wrap inner generator — finally releases slot on any exit path */ override resubscribe(params: Parameters[0], context?: ServerCallContext) { + // 1. Auth — must be first and synchronous this.requireAuthenticated(context); - return super.resubscribe(params, context); + // Fail-closed: with a limiter active, an authenticated caller missing a grantId is denied. + const grantId = this.meteredGrantId(context); + + // No limiter → unmetered (e.g. test without limiter / metering off) + if (!this.limiter) { + return super.resubscribe(params, context); + } + + // 2. Rate limit (limiter present ⟹ grantId is defined, else meteredGrantId threw) + if (!this.limiter.tryConsumeRate(grantId!)) { + throw A2AError.invalidRequest('rate limit exceeded'); + } + + // 3. Slot cap — synchronous acquire before returning generator + if (!this.limiter.tryAcquireResubscribe(grantId!)) { + throw A2AError.invalidRequest('too many concurrent resubscribe streams'); + } + + // 4. Wrap inner generator with a real wall-clock race so the stream-duration cap + // fires even when the inner stream emits no events (quiet-stream problem). + // Release slot in finally on every exit path. + const inner = super.resubscribe(params, context); + const limiter = this.limiter; + const maxMs = this.limiter.limits.maxStreamSeconds * 1000; + async function* wrapped() { + // Idempotent release: guards against the JS generator edge case where a consumer + // calls .return() before the first .next() — in that case the generator body's + // finally block does not execute. In practice the SDK SSE path always calls + // .next() before .return(), so this window is unreachable in production. + let released = false; + function release() { + if (!released) { released = true; limiter.releaseResubscribe(grantId!); } + } + const deadline = Date.now() + maxMs; + const it = inner[Symbol.asyncIterator](); + try { + while (true) { + const remaining = deadline - Date.now(); + // Fire-and-forget the inner .return(): a `.return()` issued while a prior + // `.next()` is still pending is queued behind it and does not settle until + // that next() settles — which never happens for a quiet stream (the exact + // case this cap targets). Awaiting it here would suspend `wrapped()` forever, + // skip the `finally` below, and leak the resubscribe slot. Instead we drop the + // slot immediately via `return` (→ finally → release) and let the inner + // generator wind down on its own. + if (remaining <= 0) { void it.return?.(undefined)?.catch(() => {}); return; } + let timer: ReturnType | undefined; + const timeout = new Promise<'timeout'>(res => { + timer = setTimeout(() => res('timeout'), remaining); + }); + const step = await Promise.race([it.next(), timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); + if (step === 'timeout') { void it.return?.(undefined)?.catch(() => {}); return; } + if (step.done) return; + yield step.value; + } + } finally { + release(); + } + } + return wrapped() as ReturnType; + } + + private extractGrantId(context?: ServerCallContext): string | undefined { + const p = (context?.user as any)?.a2aPrincipal as A2aPrincipal | undefined; + return p?.grantId; + } + + /** + * Resolve the grantId for metering. When a limiter is active (a2a metering enabled), + * an authenticated caller MUST carry a grantId — a missing one is treated as deny + * (fail-closed), never as "skip the limits". Without a limiter (tests / metering off) + * the caller is simply unmetered and undefined is returned. + */ + private meteredGrantId(context?: ServerCallContext): string | undefined { + const grantId = this.extractGrantId(context); + if (this.limiter && !grantId) { + throw A2AError.invalidRequest('unauthorized: missing delegation grant'); + } + return grantId; } private requireAuthenticated(context?: ServerCallContext): void { @@ -113,8 +227,10 @@ export function makeExtendedCardProvider( * 認証は makeA2aUserBuilder(UserBuilder)が担う。 */ export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void { + const limits = resolveA2aLimits(deps.limits); + const limiter = new A2aLimiter(limits); const taskStore = new SqliteA2aTaskStore(deps.repo); - const executor = new MaestroA2aExecutor(deps.repo); + const executor = new MaestroA2aExecutor(deps.repo, { limiter }); const cardDeps: CardDeps = { baseUrl: deps.baseUrl, issuer: deps.issuer, @@ -126,11 +242,13 @@ export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandle baseCard, taskStore, executor, - undefined, // eventBusManager (default) - undefined, // pushNotificationStore (not used) - undefined, // pushNotificationSender (not used) - extendedCardProvider, // 7th arg: extended card via JWT principal + extendedCardProvider, // 4th arg: forwarded to super's 7th slot + limiter, // 5th arg: shared rate/stream limiter (Task 7) ); const userBuilder = makeA2aUserBuilder(deps.provider, deps.repo); + // body-size limit: must precede jsonRpcHandler so oversized bodies are rejected + // before the SDK parser sees them. body-parser skips re-parsing when req.body is + // already set, so the SDK's own express.json() inside jsonRpcHandler won't double-parse. + app.use('/a2a', express.json({ limit: limits.maxPayloadBytes })); app.use('/a2a', jsonRpcHandler({ requestHandler: handler, userBuilder })); } diff --git a/src/bridge/admin-gateway-keys-mount.ts b/src/bridge/admin-gateway-keys-mount.ts new file mode 100644 index 0000000..730a0ef --- /dev/null +++ b/src/bridge/admin-gateway-keys-mount.ts @@ -0,0 +1,64 @@ +import express from 'express'; +import { Repository } from '../db/repository.js'; +import { logger } from '../logger.js'; +import { requireAdmin } from './auth.js'; +import { createAdminGatewayApi } from './admin-gateway-api.js'; + +/** + * AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys, extracted + * verbatim from createCoreServer (bridge/server.ts). Enabled regardless of + * gateway.enabled so an admin can prep keys before flipping the gateway on. + * + * SECURITY: this endpoint mints `sk-aao-*` bearer tokens that grant + * access to the LLM gateway. Unlike the generic admin-api (which + * exposes user-management read paths that are safe to surface in + * auth-disabled local dev), key issuance has direct production + * impact. If we mounted it without `requireAdmin` when auth is off, + * any caller reaching the server could POST to /api/admin/gateway/keys + * and walk away with a valid gateway bearer. Refuse to mount instead + * — operators who want key management MUST configure auth.providers. + */ +export function mountAdminGatewayKeysApi( + app: express.Application, + deps: { repo: Repository; authActive: boolean }, +): void { + const { repo, authActive } = deps; + if (authActive) { + app.use( + '/api/admin/gateway/keys', + express.json({ limit: '4kb' }), + requireAdmin, + createAdminGatewayApi({ + repo, + // Already gated above; pass a passthrough so the router doesn't + // try to double-guard (which would just be a no-op anyway). + requireAdmin: (_req, _res, next) => next(), + getUserId: (req) => { + const u = (req as import('express').Request & { + user?: { id?: string }; + }).user; + return u?.id ?? null; + }, + // Phase 3a F4: when the admin server runs in the same process + // as the gateway, gateway/bootstrap.ts hangs the shared cache + // off the Repository so mutations invalidate live cache state. + // Cross-process setups omit this; the cache's 5s TTL bounds + // the worst-case stale window. + keyCache: (repo as unknown as { __gatewayKeyCache?: import('../gateway/key-cache.js').KeyCache }) + .__gatewayKeyCache, + // Phase 3b post-review: same-process gateway hangs its metrics + // handle on the Repository so admin mutations can drop the + // per-key gauge labels (revoke/rotate/delete). No-op in + // cross-process deploys (the gateway process owns its own + // registry there). + gatewayMetrics: (repo as unknown as { __gatewayMetrics?: import('../metrics/gateway-metrics.js').GatewayMetrics }) + .__gatewayMetrics, + }), + ); + } else { + logger.warn( + '[admin-gateway] /api/admin/gateway/keys NOT mounted (auth disabled). ' + + 'Configure auth.providers in config.yaml to enable virtual key management.', + ); + } +} diff --git a/src/bridge/auth-subsystem.ts b/src/bridge/auth-subsystem.ts new file mode 100644 index 0000000..a37cf9b --- /dev/null +++ b/src/bridge/auth-subsystem.ts @@ -0,0 +1,172 @@ +import express, { Request, Response } from 'express'; +import { Repository } from '../db/repository.js'; +import { logger } from '../logger.js'; +import { loadConfig } from '../config.js'; +import type { AuthConfig } from '../config.js'; +import { ConfigManager } from '../config-manager.js'; +import { resolveBranding } from './branding-api.js'; +import { + setupAuth, + requireAuth, + requireAdmin, + isProviderConfigured, + isLocalEnabled, + buildChangePasswordHandler, + DEFAULT_SESSION_SECRET_PATH, +} from './auth.js'; + +/** + * Auth activation + middleware pipeline, extracted verbatim from + * createCoreServer (bridge/server.ts). Two entry points: + * + * - resolveAuthActivation: decides whether auth is active (fail-closed on a + * partial OAuth config) and seeds the synthetic 'local' user in no-auth mode. + * - mountAuthPipeline: registers session/passport middleware, /auth routes, + * /api/auth/* endpoints and the requireAuth/requireAdmin guards for the + * /api/* namespaces. Call ONLY when auth is active — registration order + * relative to all later mounts is preserved by the call site in server.ts. + */ + +/** Cookie `secure` must be set whenever the user-facing scheme is https — + * via an upstream TLS proxy (secureCookie) OR native TLS termination. */ +export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean { + return secureCookie || tlsEnabled; +} + +export function resolveAuthActivation( + repo: Repository, + authConfig: AuthConfig | undefined, +): { authActive: boolean; localEnabled: boolean } { + // Auth activates when a provider is COMPLETELY configured (clientId + + // clientSecret + callbackUrl, plus baseUrl for Gitea). + // + // FAIL CLOSED on a partial config: if the operator clearly INTENDED auth + // (a provider has a client_id) but it's incomplete (typo'd / missing + // secret/callback), we must NOT silently fall back to no-auth — that would + // fail OPEN and expose /api/local, /api/config, etc. without authentication. + // Refuse to start instead, with a clear message. (A bare `auth` block with no + // client_id at all is treated as genuine no-auth mode.) + const _authProviders = authConfig?.providers; + const authUsable = + isProviderConfigured(_authProviders?.google, 'google') || + isProviderConfigured(_authProviders?.gitea, 'gitea'); + // "Intended" = ANY OAuth field is present on a provider (not just client_id). + // Saving only client_secret/callback_url/base_url, or clearing client_id by + // mistake, must still fail closed rather than drop to no-auth. + const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) => + !!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl); + const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea); + // Local email+password is a first-class auth mode: when enabled, auth is + // active even without any OAuth provider. + const localEnabled = !!authConfig && isLocalEnabled(authConfig); + // Fail closed on a partial OAuth config ONLY when it would otherwise drop to + // no-auth. If local auth is on, auth is already active (no fail-open), so a + // half-configured OAuth provider just stays inactive rather than aborting boot. + if (authIntended && !authUsable && !localEnabled) { + throw new Error( + '[auth] auth is partially configured: a provider has a client_id but is missing ' + + 'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' + + 'insecure no-auth state — complete the provider config or remove it from config.yaml.', + ); + } + const authActive = authUsable || localEnabled; + if (!authActive) { + // No-auth single-user mode: per-user rows are owned by the synthetic + // 'local' id, and several tables FK to users(id) (ssh_user_deks, + // browser_session_profiles, …). Seed the 'local' user row so those inserts + // succeed — without it, creating an SSH connection failed with create_failed. + repo.ensureLocalUser(); + } + return { authActive, localEnabled }; +} + +export function mountAuthPipeline( + app: express.Application, + deps: { + repo: Repository; + authConfig: AuthConfig | undefined; + configManager?: ConfigManager; + localEnabled: boolean; + /** serverCfg.tls.enabled — resolved once in createCoreServer. */ + tlsEnabled: boolean; + }, +): import('./auth.js').UpgradeAuthChecker | undefined { + const { repo, authConfig, configManager, localEnabled, tlsEnabled } = deps; + + // Idempotently seed the shared `local` system admin (id='local', the same + // owner the no-auth path uses) so an existing single-user / no-auth + // deployment gains a login mid-stream and keeps its `local`-owned data. + const bootstrap = authConfig?.local?.bootstrapAdmin; + if (localEnabled && bootstrap?.email && bootstrap?.password) { + repo.upsertLocalSystemAdmin({ email: bootstrap.email, password: bootstrap.password }); + logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`); + } + + // Compose effective secureCookie: native TLS termination also requires + // the secure flag on session cookies, even when no upstream proxy is + // present. IMPORTANT: trust-proxy (set near the top of createCoreServer) + // stays keyed on the ORIGINAL opts.authConfig.secureCookie — native TLS + // must NOT enable it. + const effectiveSecureCookie = computeEffectiveSecureCookie( + !!authConfig?.secureCookie, + tlsEnabled, + ); + const authConfigForSetup = authConfig + ? { ...authConfig, secureCookie: effectiveSecureCookie } + : authConfig; + + const auth = setupAuth( + repo, + authConfigForSetup!, + () => { + const b = resolveBranding(configManager); + return { appName: b.appName, loginPageTitle: b.loginPageTitle }; + }, + loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH, + ); + const { sessionMiddleware, passportInit, passportSession, authRouter } = auth; + const authenticateUpgrade = auth.authenticateUpgrade; + + // Global session + passport middleware (BEFORE all routes) + app.use(sessionMiddleware); + app.use(passportInit); + app.use(passportSession); + + // Auth routes (unauthenticated access allowed) + app.use('/auth', authRouter); + + // /api/auth/me endpoint + app.get('/api/auth/me', requireAuth, (req: Request, res: Response) => { + res.json(req.user); + }); + + // Self-service password change for local accounts (see auth.ts). + app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo)); + + // Protect all API routes (except /api/version and /health) + app.use('/api/local', requireAuth); + app.use('/api/repos', requireAuth); + // /api/workers exposes endpoint URLs + proxy backend probing. Without + // auth, unauthenticated callers could (a) enumerate worker endpoints + // and (b) trigger upstream `/v1/models` fetches against any + // attacker-influenced endpoint (SSRF amplifier). Gate behind requireAuth + // — admin-only is unnecessary because the responses already strip + // sensitive fields (apiKey), but anonymous access must be blocked. + app.use('/api/workers', requireAuth); + + // Admin-only routes + app.use('/api/config', requireAdmin); + // /api/pieces: any authenticated user can GET (visibility-filtered); + // per-piece write authz (built-in/global-custom → admin, user-custom → owner) + // is enforced inside pieces-api.ts handlers. + app.use('/api/pieces', requireAuth); + app.use('/api/usage', requireAuth); + // 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは + // Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。 + app.use('/api/calendar', requireAuth); + // Scheduled tasks: any authenticated user can create/list (visibility-filtered). + // PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14). + app.use('/api/scheduled-tasks', requireAuth); + + return authenticateUpgrade; +} diff --git a/src/bridge/bridge-gateway-mount.ts b/src/bridge/bridge-gateway-mount.ts new file mode 100644 index 0000000..fad853e --- /dev/null +++ b/src/bridge/bridge-gateway-mount.ts @@ -0,0 +1,102 @@ +import express, { Request, Response } from 'express'; +import { Repository } from '../db/repository.js'; +import { logger } from '../logger.js'; +import { loadConfig } from '../config.js'; +import { ConfigManager } from '../config-manager.js'; +import { requireAdmin } from './auth.js'; +import { registerShutdownHook } from './shutdown.js'; +import { mountGateway, type GatewayMountHandle } from './gateway-mount.js'; +import { readGatewayConfig } from '../gateway/config.js'; +import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; +import { resolveListenPort } from '../server/config.js'; + +/** + * Phase 3c — same-process AAO Gateway mount + the bridge `/health` fallback + + * the admin gateway status endpoint, extracted verbatim from createCoreServer + * (bridge/server.ts). The registration order of the three blocks (gateway + * gate BEFORE `/health` BEFORE status) is behavior-relevant and preserved. + */ +export function mountBridgeGateway( + app: express.Express, + deps: { + repo: Repository; + configManager?: ConfigManager; + /** Shared prom-client registry from setupMetricsSubsystem (null when metrics are disabled). */ + sharedPromRegistry: import('prom-client').Registry | null; + authActive: boolean; + listenPort?: number; + }, +): GatewayMountHandle | null { + const { repo, configManager, sharedPromRegistry, authActive, listenPort } = deps; + + // Phase 3c — same-process AAO Gateway mount. Always installs the + // dynamic 404 gate; the gateway sub-app only comes alive when + // `gateway.enabled: true` in config.yaml (or after an admin flips it + // ON via Settings → Gateway Server). Pure no-op when no ConfigManager + // is available (tests / scripts run without one). + let gatewayMount: GatewayMountHandle | null = null; + if (configManager) { + gatewayMount = mountGateway({ + app, + configManager, + repo, + // Per CRITICAL-2: the gateway now owns its own BackendStatusRegistry + // over gateway.backends[] rather than reusing the worker registry + // (which probes provider.workers[] — different IDs). Same-host + // double-probe is intentional and cheap. + // Reuse the worker's prom-client registry so gateway counters + // appear on the same /metrics endpoint (no port collision). + promRegistry: sharedPromRegistry, + metricsPrefix: 'aao_gateway', + }); + // Apply the boot-time config so a server starting with + // `gateway.enabled: true` brings the gateway up immediately — + // no need to re-save config from the UI. + const bootGateway = readGatewayConfig(configManager.getConfig()); + gatewayMount.applyConfig(bootGateway).catch((e) => { + logger.warn(`[bridge-gateway] initial applyConfig threw: ${e instanceof Error ? e.message : String(e)}`); + }); + // Drain in-flight gateway streams on process shutdown so SIGTERM + // doesn't strand SSE clients with a half-written response. + registerShutdownHook('bridge-gateway', async () => { + try { await gatewayMount!.stop(); } catch { /* noop */ } + }); + } else { + logger.info('[bridge-gateway] not mounted (no ConfigManager — hot reload unavailable)'); + } + + // CRITICAL-3 fix: bridge `/health` fallback. Registered AFTER the + // gateway gate so when gateway.enabled=true the gate dispatches + // `/health` into the gateway sub-app (LiteLLM-compat + // `healthy_endpoints` / `unhealthy_endpoints` JSON shape). When the + // gateway is off, the gate falls through to here and the bridge + // answers with the legacy `{status:'ok'}` shape ops scripts rely on. + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok' }); + }); + + // Admin status endpoint for the Gateway Server UI. Read-only — the + // form drives state changes through PUT /api/config (the + // config-changed listener inside mountGateway picks them up). + // Requires admin when auth is active; in auth-off dev mode the + // endpoint is open like the rest of /api/admin/* health/status reads. + { + // Prefer the explicit listenPort threaded through CoreServerOptions + // (startCoreServer always sets it) over the PORT env-var guess so + // the status endpoint matches what the bridge actually bound. + // env-var fallback is kept for callers that bypass startCoreServer. + const actualPort = listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port); + const statusRouter = createAdminGatewayStatusRouter({ + mount: gatewayMount, + configManager: configManager ?? null, + workerPort: actualPort, + }); + if (authActive) { + app.use('/api/admin/gateway/status', requireAdmin, statusRouter); + } else { + app.use('/api/admin/gateway/status', statusRouter); + } + } + + return gatewayMount; +} diff --git a/src/bridge/jobs-api.ts b/src/bridge/jobs-api.ts new file mode 100644 index 0000000..46ac48a --- /dev/null +++ b/src/bridge/jobs-api.ts @@ -0,0 +1,35 @@ +import express, { Request, Response } from 'express'; +import { Repository } from '../db/repository.js'; +import { requireAuth } from './auth.js'; + +/** + * Job detail API (`GET /api/jobs/:jobId`), extracted verbatim from + * createCoreServer (bridge/server.ts). Registration order is preserved by + * the mount call site in server.ts. + */ +export function mountJobsApi( + app: express.Application, + deps: { repo: Repository; authActive: boolean }, +): void { + const { repo, authActive } = deps; + + // --- Job detail API --- + // Gate on viewer: getJob returns null for jobs the caller cannot see (Task 10 + 16). + // When auth is inactive, req.user is undefined → repository falls back to 1=1 (no filter). + const jobDetailHandlers: express.RequestHandler[] = []; + if (authActive) jobDetailHandlers.push(requireAuth); + jobDetailHandlers.push(async (req: Request, res: Response) => { + try { + const viewer = (req.user as Express.User | undefined) ?? undefined; + const job = await repo.getJob(req.params.jobId, viewer ? { viewer } : undefined); + if (!job) { + res.status(404).json({ error: 'Job not found' }); + return; + } + res.json(job); + } catch { + res.status(500).json({ error: 'Failed to fetch job' }); + } + }); + app.get('/api/jobs/:jobId', ...jobDetailHandlers); +} diff --git a/src/bridge/local-tasks-api.ts b/src/bridge/local-tasks-api.ts index fc9ba90..cd498ec 100644 --- a/src/bridge/local-tasks-api.ts +++ b/src/bridge/local-tasks-api.ts @@ -5,6 +5,7 @@ import { type LocalTasksDeps } from './local-tasks-shared.js'; import { registerLocalTaskCrudRoutes } from './local-tasks-crud-api.js'; import { registerLocalTaskCommentsRoutes } from './local-tasks-comments-api.js'; import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests-api.js'; +import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js'; import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js'; import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js'; @@ -61,6 +62,13 @@ export interface LocalTasksApiOptions { }) => Promise; /** Timeout (ms) for a prompt-coach evaluation before it is aborted. Default 30000. */ evaluatePromptTimeoutMs?: number; + /** + * Package-request mechanism (PR3): returns the current Python packages config + * so an approval can install into the task's space overlay. Called per + * request so config changes take effect without a restart. When unset, + * approving a package request fails with 400 (feature disabled). + */ + getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined; } /** @@ -86,6 +94,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions) registerLocalTaskCrudRoutes(app, deps); registerLocalTaskToolRequestRoutes(app, deps); + registerLocalTaskPackageRequestRoutes(app, deps); registerLocalTaskCommentsRoutes(app, deps); registerLocalTaskControlRoutes(app, deps); registerLocalTaskStreamRoutes(app, deps); diff --git a/src/bridge/local-tasks-comments-api.ts b/src/bridge/local-tasks-comments-api.ts index 49a153c..ef5190e 100644 --- a/src/bridge/local-tasks-comments-api.ts +++ b/src/bridge/local-tasks-comments-api.ts @@ -126,8 +126,9 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas // leaves no orphan files in input/) and again after attachments (below) // to close the TOCTOU where the job flips to tool_request mid-upload. const earlyJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); - if (earlyJob && earlyJob.status === 'waiting_human' && earlyJob.waitReason === 'tool_request') { - res.status(409).json({ error: '保留中のツール要求があります。先に許可または拒否してください。' }); + if (earlyJob && earlyJob.status === 'waiting_human' + && (earlyJob.waitReason === 'tool_request' || earlyJob.waitReason === 'package_request')) { + res.status(409).json({ error: '保留中の承認要求があります。先に許可または拒否してください。' }); return; } @@ -204,10 +205,10 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas browserSessionProfileId: task!.browserSessionProfileId ?? null, spaceId: task!.spaceId ?? null, }, { blockOnToolRequestPause: true }); - // Blocked by a tool-approval pause → return BEFORE persisting the comment - // so a rejected post leaves no orphan comment tied to no job. + // Blocked by a tool/package-approval pause → return BEFORE persisting the + // comment so a rejected post leaves no orphan comment tied to no job. if (blockedByToolRequest) { - res.status(409).json({ error: '保留中のツール要求があります。先に許可または拒否してください。' }); + res.status(409).json({ error: '保留中の承認要求があります。先に許可または拒否してください。' }); return; } diff --git a/src/bridge/local-tasks-package-requests-api.test.ts b/src/bridge/local-tasks-package-requests-api.test.ts new file mode 100644 index 0000000..88782ad --- /dev/null +++ b/src/bridge/local-tasks-package-requests-api.test.ts @@ -0,0 +1,184 @@ +/** + * Authz + gating tests for /api/local/tasks/:taskId/package-requests(/:id/decide). + * + * These cover the paths that DO NOT trigger a real pip install (view gate, + * write gate, feature gate, deny, already-decided) so the suite stays hermetic + * — no network / no bwrap / no python3. The install/overlay mechanics are + * unit-tested in engine/python-packages.test.ts, and the shared add path in + * space-api.python-packages.test.ts. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import request from 'supertest'; +import { Repository, localTaskRepoName } from '../db/repository.js'; +import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js'; +import type { LocalTasksDeps } from './local-tasks-shared.js'; +import type { PythonPackagesConfigShape } from '../config.js'; + +function asUser(u: { id: string; role: 'admin' | 'user' }): Express.User { + return { id: u.id, role: u.role, orgIds: [] } as unknown as Express.User; +} + +function makeApp(repo: Repository, currentUser: Express.User | undefined, pkgCfg?: PythonPackagesConfigShape) { + const app = express(); + app.use((req: express.Request, _res, next) => { if (currentUser) (req as { user?: Express.User }).user = currentUser; next(); }); + const deps = { + repo, + userFolderRoot: tmpdir(), + noAuthOwner: undefined, + opts: { repo, authActive: true, getPythonPackagesConfig: () => pkgCfg }, + } as unknown as LocalTasksDeps; + registerLocalTaskPackageRequestRoutes(app, deps); + return app; +} + +let repo: Repository; +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pkgreq-api-')); + repo = new Repository(join(dir, `${randomUUID()}.db`)); +}); +afterEach(() => { + repo.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +async function seed() { + const owner = repo.createUser({ email: 'o@e.com', name: 'O', role: 'user', status: 'active' }); + const viewer = repo.createUser({ email: 'v@e.com', name: 'V', role: 'user', status: 'active' }); + const stranger = repo.createUser({ email: 's@e.com', name: 'S', role: 'user', status: 'active' }); + // Private space so a non-member truly cannot see the task (→ 404), while a + // viewer member can see but not edit (→ 403). + const space = await repo.createSpace({ kind: 'case', title: 'T', ownerId: owner.id, visibility: 'private' }); + await repo.addSpaceMember({ spaceId: space.id, userId: viewer.id, role: 'viewer' }); + const task = await repo.createLocalTask({ + title: 't', body: 'b', pieceName: 'chat', ownerId: owner.id, visibility: 'private', spaceId: space.id, + }); + const reqId = repo.recordPackageRequest({ + taskId: String(task.id), jobId: null, spaceId: space.id, + spec: 'requests==2.32.3', normalizedName: 'requests', reason: 'http', + }); + return { owner, viewer, stranger, space, task, reqId }; +} + +const ENABLED: PythonPackagesConfigShape = { enabled: true } as PythonPackagesConfigShape; +const DISABLED: PythonPackagesConfigShape = { enabled: false } as PythonPackagesConfigShape; + +describe('package-requests API', () => { + it('GET requires view permission (stranger gets 404/403)', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.stranger), ENABLED); + const res = await request(app).get(`/api/local/tasks/${fx.task.id}/package-requests`); + expect([403, 404]).toContain(res.status); + }); + + it('GET lists requests for the owner', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).get(`/api/local/tasks/${fx.task.id}/package-requests`); + expect(res.status).toBe(200); + expect(res.body.packageRequests).toHaveLength(1); + expect(res.body.packageRequests[0].spec).toBe('requests==2.32.3'); + }); + + it('decide rejects a bad decision value', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'maybe' }); + expect(res.status).toBe(400); + }); + + it('a space viewer (editor未満) cannot decide → 403', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.viewer), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); + expect(res.status).toBe(403); + // untouched + expect(repo.getPackageRequest(fx.reqId)?.status).toBe('pending'); + }); + + it('a non-member cannot decide → 404 (task not visible)', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.stranger), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); + expect(res.status).toBe(404); + }); + + it('owner can deny → request denied, resume no-ops without a job', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); + expect(res.status).toBe(200); + expect(res.body.decision).toBe('deny'); + expect(repo.getPackageRequest(fx.reqId)?.status).toBe('denied'); + }); + + it('deciding an already-decided request → 409', async () => { + const fx = await seed(); + repo.decidePackageRequest(fx.reqId, { status: 'denied', decidedBy: fx.owner.id }); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); + expect(res.status).toBe(409); + }); + + it('approve with the feature disabled → 400 (no install attempted)', async () => { + const fx = await seed(); + const app = makeApp(repo, asUser(fx.owner), DISABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'approve' }); + expect(res.status).toBe(400); + expect(repo.getPackageRequest(fx.reqId)?.status).toBe('pending'); + }); + + it('admin (not a member) can decide', async () => { + const fx = await seed(); + const admin = repo.createUser({ email: 'a@e.com', name: 'A', role: 'admin', status: 'active' }); + const app = makeApp(repo, asUser(admin), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${fx.reqId}/decide`).send({ decision: 'deny' }); + expect(res.status).toBe(200); + expect(repo.getPackageRequest(fx.reqId)?.status).toBe('denied'); + }); + + it('deny re-queues a job parked with wait_reason=package_request (park/resume contract)', async () => { + // Regression guard for the worker-park contract: a package-approval pause + // MUST be persisted as wait_reason='package_request' for resume to work. + const fx = await seed(); + const job = await repo.createJob({ + repo: localTaskRepoName(fx.task.id), issueNumber: fx.task.id, + instruction: 'x', pieceName: 'chat', ownerId: fx.owner.id, spaceId: fx.space.id, + }); + repo.getDb().prepare(`UPDATE jobs SET status='waiting_human', wait_reason='package_request' WHERE id = ?`).run(job.id); + const reqId = repo.recordPackageRequest({ + taskId: String(fx.task.id), jobId: job.id, spaceId: fx.space.id, + spec: 'httpx', normalizedName: 'httpx', + }); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${reqId}/decide`).send({ decision: 'deny' }); + expect(res.status).toBe(200); + expect(res.body.resumed).toBe(true); + expect((await repo.getJob(job.id))?.status).toBe('queued'); + }); + + it('does NOT resume a job parked without wait_reason=package_request', async () => { + // The wait_reason gate must be exact: a job parked for a different reason is + // never re-queued by resumePackageRequestJob. + const fx = await seed(); + const job = await repo.createJob({ + repo: localTaskRepoName(fx.task.id), issueNumber: fx.task.id, + instruction: 'x', pieceName: 'chat', ownerId: fx.owner.id, spaceId: fx.space.id, + }); + repo.getDb().prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(job.id); + const reqId = repo.recordPackageRequest({ + taskId: String(fx.task.id), jobId: job.id, spaceId: fx.space.id, spec: 'httpx', normalizedName: 'httpx', + }); + const app = makeApp(repo, asUser(fx.owner), ENABLED); + const res = await request(app).post(`/api/local/tasks/${fx.task.id}/package-requests/${reqId}/decide`).send({ decision: 'deny' }); + expect(res.status).toBe(200); + expect(res.body.resumed).toBe(false); // wait_reason mismatch → not re-queued + expect((await repo.getJob(job.id))?.status).toBe('waiting_human'); + }); +}); diff --git a/src/bridge/local-tasks-package-requests-api.ts b/src/bridge/local-tasks-package-requests-api.ts new file mode 100644 index 0000000..5d0d9cf --- /dev/null +++ b/src/bridge/local-tasks-package-requests-api.ts @@ -0,0 +1,163 @@ +import express, { type Application, type Request, type Response } from 'express'; +import { logger } from '../logger.js'; +import { parseTaskId } from './validation.js'; +import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js'; +import { type LocalTasksDeps, resolveTaskWriteGate } from './local-tasks-shared.js'; +import { + resolvePythonPackagesConfig, + parseAndValidateSpec, + assertNotShadowing, +} from '../engine/python-packages.js'; +import { isBwrapAvailable } from '../engine/tools/sandbox.js'; +import { addPackageToSpace } from './space-python-packages-service.js'; + +/** + * Package-request mechanism (PR3): list a task's recorded Python package + * requests, and approve/deny a pending one. + * + * SECURITY MODEL: + * - Listing needs task VIEW permission. + * - Deciding needs task WRITE permission (owner / admin / space editor) — an + * agent can NEVER approve its own request. + * - Approving installs ONLY through the shared installSpacePackages path + * (bwrap-isolated / wheels-only / fixed index / stdlib+preinstalled + * shadowing guards / index URL never leaked), and re-validates the stored + * spec (defense in depth). The install is idempotent (content-addressed + + * name-deduped), so a double approve never double-installs. + */ +export function registerLocalTaskPackageRequestRoutes(app: Application, deps: LocalTasksDeps): void { + const { repo } = deps; + + // List the package requests recorded for a task (RequestPackage declarations). + app.get('/api/local/tasks/:taskId/package-requests', async (req: Request, res: Response) => { + try { + const taskId = parseTaskId(req.params.taskId); + if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } + const viewer = req.user as Express.User | undefined; + const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); + if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return; + res.json({ packageRequests: repo.listPackageRequestsByTask(String(taskId)) }); + } catch (err) { + logger.error(`Package requests list API error: ${err}`); + res.status(500).json({ error: 'Failed to list package requests' }); + } + }); + + // Approve (install into the space overlay + resume) or deny a pending package + // request. Requires task write permission. + app.post('/api/local/tasks/:taskId/package-requests/:reqId/decide', express.json(), async (req: Request, res: Response) => { + try { + const taskId = parseTaskId(req.params.taskId); + if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } + const reqId = String(req.params.reqId); + const decision = (req.body as { decision?: unknown })?.decision; + if (decision !== 'approve' && decision !== 'deny') { + res.status(400).json({ error: 'decision must be "approve" or "deny"' }); + return; + } + const viewer = req.user as Express.User | undefined; + const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); + const gate = await resolveTaskWriteGate(repo, viewer, task ? { ownerId: task.ownerId, spaceId: task.spaceId } : null); + if (gate !== 'allow') { + res.status(gate === 'view-only' ? 403 : 404) + .json({ error: gate === 'view-only' ? 'No permission to decide package requests' : 'Task not found' }); + return; + } + const pr = repo.getPackageRequest(reqId); + if (!pr || pr.taskId !== String(taskId)) { res.status(404).json({ error: 'Package request not found' }); return; } + if (pr.status !== 'pending') { res.status(409).json({ error: `Already decided (${pr.status})` }); return; } + + const decidedBy = viewer?.id ?? null; + + if (decision === 'deny') { + // CAS: if a concurrent approve already claimed it, don't resume-as-denied. + if (!repo.decidePackageRequest(reqId, { status: 'denied', decidedBy })) { + res.status(409).json({ error: 'Already decided' }); + return; + } + const resumed = pr.jobId ? repo.resumePackageRequestJob(pr.jobId) : 0; + res.json({ ok: true, decision, spec: pr.spec, resumed: resumed > 0 }); + return; + } + + // ---- approve ---- + // Validate everything cheap BEFORE claiming so a bad config doesn't burn + // the pending state. + if (!pr.spaceId) { + // Requests are only recorded with a space (worker gate), so this is a + // defensive guard: without a space there is nowhere to persist the + // desired-state / overlay. + res.status(422).json({ error: 'This request has no workspace to install into.' }); + return; + } + const cfg = resolvePythonPackagesConfig(deps.opts.getPythonPackagesConfig?.()); + if (!cfg.enabled) { + res.status(400).json({ error: 'Python package management is disabled (config: python_packages.enabled)' }); + return; + } + // Re-validate the stored spec (defense in depth — the DB holds a string). + let parsed; + try { + parsed = parseAndValidateSpec(pr.spec); + assertNotShadowing(parsed.normalizedName); + } catch (e) { + res.status(400).json({ error: `invalid stored spec: ${(e as Error).message}` }); + return; + } + const bwrapAvailable = await isBwrapAvailable(); + if (!bwrapAvailable) { + // Installs are fail-closed: never run pip without the isolated sandbox. + res.status(503).json({ error: 'The bwrap sandbox is unavailable on this server, so packages cannot be installed safely.' }); + return; + } + + // CLAIM the request (CAS pending→approved) BEFORE installing. This closes + // the race where a concurrent deny marks it denied while we install: if + // the claim loses (another decide won), we abort WITHOUT installing. On + // install failure we revert to pending so it can be retried. + if (!repo.decidePackageRequest(reqId, { status: 'approved', decidedBy })) { + res.status(409).json({ error: 'Already decided' }); + return; + } + + // ownerId is only a fallback arg to spaceKeyFor (ignored when a space id + // is present), but fetch it for parity with the admin form. + const space = await repo.getSpace(pr.spaceId, viewer ? { viewer } : undefined); + let result; + try { + result = await addPackageToSpace({ + repo, + cfg, + spaceId: pr.spaceId, + ownerId: space?.ownerId ?? null, + parsed, + bwrapAvailable, + }); + } catch (e) { + repo.revertPackageRequestToPending(reqId); + throw e; + } + if ('installError' in result || 'tooMany' in result || 'versionConflict' in result) { + // Revert so the (still-parked) job can be re-approved / denied after a fix. + repo.revertPackageRequestToPending(reqId); + if ('tooMany' in result) { + res.status(400).json({ error: `too many packages (max ${cfg.maxPackagesPerSpace})` }); + } else if ('versionConflict' in result) { + res.status(409).json({ error: `already pinned to a different version: ${result.versionConflict}. Change it in Settings → Python first.` }); + } else { + res.status(422).json({ error: result.installError }); + } + return; + } + // `conflict` (already installed) OR `ok` are both success: the overlay + // already contains the wheel, so the request is satisfied. The claim above + // already set status=approved. + const resumed = pr.jobId ? repo.resumePackageRequestJob(pr.jobId) : 0; + logger.info(`[local-tasks] package request approved task=${taskId} spec=${pr.spec} space=${pr.spaceId}`); + res.json({ ok: true, decision, spec: pr.spec, resumed: resumed > 0 }); + } catch (err) { + logger.error(`Package request decide API error: ${err}`); + res.status(500).json({ error: 'Failed to decide package request' }); + } + }); +} diff --git a/src/bridge/metrics-subsystem.ts b/src/bridge/metrics-subsystem.ts new file mode 100644 index 0000000..8d7a697 --- /dev/null +++ b/src/bridge/metrics-subsystem.ts @@ -0,0 +1,52 @@ +import express from 'express'; +import { logger } from '../logger.js'; +import { ConfigManager } from '../config-manager.js'; +import { createWorkerRegistry } from '../metrics/registry.js'; +import { createWorkerMetrics, type WorkerMetrics } from '../metrics/worker-metrics.js'; +import { createMetricsHandler } from '../metrics/http-handler.js'; + +/** + * Phase 3b: Prometheus /metrics endpoint, extracted verbatim from + * createCoreServer (bridge/server.ts). Mounted BEFORE any auth middleware so + * the standard Prometheus scrape job works without a session cookie. + * Restrict scrape access at the reverse proxy / firewall layer (see help + * doc). Disabled by provider.metrics.enabled=false in config.yaml. + */ +export function setupMetricsSubsystem( + app: express.Application, + configManager: ConfigManager | undefined, +): { + workerMetrics: WorkerMetrics | null; + /** Phase 3c: shared prom-client registry so the same-process gateway mount + * can register its gateway_* counters into the same /metrics endpoint + * (one scrape serves both worker and gateway). Null when metrics are + * disabled. */ + sharedPromRegistry: import('prom-client').Registry | null; +} { + const appConfig = (() => { + try { return configManager?.getConfig() ?? null; } catch { return null; } + })(); + const metricsCfg = appConfig?.provider?.metrics; + let workerMetrics: WorkerMetrics | null = null; + let sharedPromRegistry: import('prom-client').Registry | null = null; + if (metricsCfg?.enabled !== false) { + const prefix = metricsCfg?.prefix ?? 'aao_worker'; + sharedPromRegistry = createWorkerRegistry(prefix); + workerMetrics = createWorkerMetrics(sharedPromRegistry, prefix); + // Phase 3b post-review: gate /metrics on (1) bearer token if set, + // (2) otherwise client-IP allowlist (default localhost). Labels + // like `worker_id` / `backend_id` would leak otherwise. + const metricsAuth = { + bearerToken: metricsCfg?.bearerToken, + allowedHosts: metricsCfg?.allowedHosts, + }; + app.get('/metrics', createMetricsHandler(sharedPromRegistry, metricsAuth)); + const authMode = metricsAuth.bearerToken + ? 'bearer' + : `ip-allowlist=${(metricsAuth.allowedHosts ?? ['127.0.0.1', '::1', 'localhost']).join(',')}`; + logger.info(`[bridge] worker metrics enabled prefix=${prefix} auth=${authMode}`); + } else { + logger.info('[bridge] worker metrics disabled (provider.metrics.enabled=false)'); + } + return { workerMetrics, sharedPromRegistry }; +} diff --git a/src/bridge/novnc-mount.ts b/src/bridge/novnc-mount.ts new file mode 100644 index 0000000..f833294 --- /dev/null +++ b/src/bridge/novnc-mount.ts @@ -0,0 +1,37 @@ +import express from 'express'; +import { Repository } from '../db/repository.js'; +import { createNovncRouter, type NovncSessionAuthorizer } from './novnc-proxy.js'; +import { canUserSeeTask } from './visibility.js'; + +/** + * noVNC static hosting + the session authorizer closure, extracted verbatim + * from createCoreServer (bridge/server.ts). + */ +export function mountNovncStatic(app: express.Application): void { + // noVNC static files + app.use('/novnc', createNovncRouter()); +} + +/** + * CAPTCHA Pool は admin、Task Session はタスク visibility で認可。 + * novnc-proxy は Repository を直接知らずに済むよう、判定ロジックは + * ここでクロージャに包んで渡す。 + */ +export function buildNovncSessionAuthorizer(repo: Repository): NovncSessionAuthorizer { + return async (session, user) => { + if (session.kind === 'pool') { + return user.role === 'admin'; + } + if (session.kind === 'task' && session.taskId) { + const taskIdNum = Number(session.taskId); + if (!Number.isFinite(taskIdNum)) return false; + const task = await repo.getLocalTask(taskIdNum); + if (!task) return false; + const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false; + return canUserSeeTask(user, task, spaceAccessible); + } + // legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin + const isOwner = session.userId === user.id; + return isOwner || user.role === 'admin'; + }; +} diff --git a/src/bridge/server-route-order.test.ts b/src/bridge/server-route-order.test.ts new file mode 100644 index 0000000..a19b608 --- /dev/null +++ b/src/bridge/server-route-order.test.ts @@ -0,0 +1,88 @@ +/** + * Characterization test pinning the TOP-LEVEL Express layer registration + * order of createCoreServer. Express dispatches in registration order, so + * extracting inline route blocks out of server.ts must keep this fingerprint + * byte-identical — any drift means the refactor changed observable routing + * behavior, not just structure. + * + * Two boots are pinned: + * 1. minimal no-auth boot ({ repo, worktreeDir }) — the same shape + * server-subsystems.test.ts uses. + * 2. auth-active boot (local auth) + ConfigManager — exercises the auth + * middleware pipeline, /api/* guards, setup/config mounts and the + * gateway mount/health/status ordering. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import type { Application } from 'express'; +import { Repository } from '../db/repository.js'; +import { ConfigManager } from '../config-manager.js'; +import { createCoreServer } from './server.js'; +import type { AuthConfig } from '../config.js'; + +interface Layer { + route?: { path: string; methods: Record }; + name?: string; + regexp?: RegExp; +} + +/** Ordered fingerprint of the app's top-level middleware/route layers. */ +function fingerprint(app: Application): string[] { + const stack: Layer[] = (app as unknown as { _router?: { stack: Layer[] } })._router?.stack ?? []; + return stack.map((layer) => { + if (layer.route) { + const methods = Object.keys(layer.route.methods).sort().join(','); + return `ROUTE ${methods} ${layer.route.path}`; + } + return `USE name=${layer.name ?? ''} path=${layer.regexp?.source ?? ''}`; + }); +} + +function mkAuthConfig(): AuthConfig { + return { + sessionSecret: 'test-secret', + sessionMaxAge: 3600_000, + secureCookie: false, + adminEmails: [], + providers: {}, + local: { enabled: true, allowSignup: false }, + } as AuthConfig; +} + +describe('createCoreServer route registration order', () => { + let tempDir: string; + let repo: Repository; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'route-order-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + }); + + afterEach(() => { + delete process.env.MCP_ENCRYPTION_KEY; + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('pins the no-auth minimal boot layer order', () => { + const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') }); + expect(fingerprint(app)).toMatchSnapshot(); + }); + + it('pins the auth-active + ConfigManager boot layer order', () => { + const configPath = join(tempDir, 'config.yaml'); + writeFileSync(configPath, 'provider:\n workers: []\n'); + const configManager = new ConfigManager(configPath); + const { app, authActive } = createCoreServer({ + repo, + worktreeDir: join(tempDir, 'wt'), + authConfig: mkAuthConfig(), + configManager, + listenPort: 9876, + }); + expect(authActive).toBe(true); + expect(fingerprint(app)).toMatchSnapshot(); + }); +}); diff --git a/src/bridge/server.ts b/src/bridge/server.ts index ac0182b..d18b96f 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -1,47 +1,42 @@ import express, { Request, Response, NextFunction, RequestHandler } from 'express'; -import { existsSync, readFileSync } from 'fs'; -import { join, resolve, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { existsSync } from 'fs'; +import { join } from 'path'; import { Repository, BrowserSessionRepo } from '../db/repository.js'; import { logger } from '../logger.js'; import { loadConfig } from '../config.js'; import { ConfigManager } from '../config-manager.js'; -import { mountConfigApi } from './config-api.js'; -import { - mountSetupApi, - buildNoAuthConfigGate, - newNoAuthConfigGateState, - ensureSetupToken, - clearSetupToken, -} from './setup-api.js'; +import { mountSetupAndConfigApis } from './setup-config-mount.js'; import { mountPiecesApi } from './pieces-api.js'; import { resolveSpaceFolder } from '../spaces/folder-resolver.js'; import { mountToolsApi } from './tools-api.js'; import { mountSkillsApi } from './skills-api.js'; import { mountNotificationsApi } from './notifications-api.js'; import { mountScheduledTasksApi } from './scheduled-tasks-api.js'; -import { mountBrandingApi, resolveBranding } from './branding-api.js'; +import { mountBrandingApi } from './branding-api.js'; import { createBrowserApi } from './browser-api.js'; import { createBrowserSessionApi } from './browser-session-api.js'; import { createSubtaskActivityRouter } from './subtask-activity-api.js'; import { createDelegateRunsRouter } from './delegate-runs-api.js'; import { createUsageRouter } from './usage-api.js'; import { SessionManager } from '../engine/browser-session.js'; -import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js'; +import { setupNovncWebSocketProxy } from './novnc-proxy.js'; +import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js'; import { setSessionManager } from '../engine/tools/browser.js'; import { setUserFolderToolDeps } from '../engine/tools/user-folder.js'; import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setAppDocsDeps } from '../engine/tools/app-docs.js'; -import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, DEFAULT_SESSION_SECRET_PATH } from './auth.js'; -import { canUserSeeTask } from './visibility.js'; +import { requireAuth, requireAdmin } from './auth.js'; +import { resolveAuthActivation, mountAuthPipeline } from './auth-subsystem.js'; import { mountAdminApi } from './admin-api.js'; -import { createAdminGatewayApi } from './admin-gateway-api.js'; +import { mountAdminGatewayKeysApi } from './admin-gateway-keys-mount.js'; import { mountUsersApi, mountUsersPickableApi } from './users-api.js'; import { mountShareApi } from './share-api.js'; import { mountAppShareApi } from './app-share-api.js'; import { mountMetaApi } from './meta-api.js'; import { securityHeadersMiddleware } from './security-headers.js'; import { mountLocalTasksApi } from './local-tasks-api.js'; +import { mountJobsApi } from './jobs-api.js'; +import { mountUiStatic } from './ui-static.js'; import { findPieceFile } from './pieces-api.js'; import { mountLocalFilesApi } from './local-files-api.js'; import { mountSubtaskFilesApi } from './subtask-files-api.js'; @@ -56,33 +51,23 @@ import { mountAppHarnessApi } from './app-harness-api.js'; import { createCrossCalendarApi } from './cross-calendar-api.js'; import { registerShutdownHook, installSignalHandlers } from './shutdown.js'; import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js'; -import { createWorkerRegistry } from '../metrics/registry.js'; -import { createWorkerMetrics, type WorkerMetrics } from '../metrics/worker-metrics.js'; -import { createMetricsHandler } from '../metrics/http-handler.js'; +import type { WorkerMetrics } from '../metrics/worker-metrics.js'; +import { setupMetricsSubsystem } from './metrics-subsystem.js'; import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js'; import { startTrashCleanup } from '../user-folder/trash-cleanup.js'; import { userPiecesDir } from '../user-folder/paths.js'; import { startReflectionRetentionSweep } from '../engine/reflection/retention.js'; import type { AuthConfig } from '../config.js'; import { attachConsoleWs } from './console-ws-api.js'; -import { mountGateway, type GatewayMountHandle } from './gateway-mount.js'; -import { readGatewayConfig } from '../gateway/config.js'; -import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js'; -import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js'; -import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js'; -import { mountA2aRequestHandler } from './a2a/request-handler.js'; -import { A2aTaskReconciler } from './a2a/reconciler.js'; -import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js'; -import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; +import type { GatewayMountHandle } from './gateway-mount.js'; +import { mountBridgeGateway } from './bridge-gateway-mount.js'; +import { setupA2aSubsystem } from './a2a-subsystem.js'; import { createServer as createHttpsServer } from 'https'; import { X509Certificate } from 'crypto'; import { mergeServerConfig, resolveListenPort } from '../server/config.js'; import { resolveTlsOptions } from '../net/tls-options.js'; import { createHttpRedirectServer } from '../net/http-redirect.js'; -const __filenameServer = fileURLToPath(import.meta.url); -const __dirnameServer = dirname(__filenameServer); - export interface CoreServerOptions { repo: Repository; worktreeDir?: string; @@ -179,35 +164,7 @@ export function createCoreServer(opts: CoreServerOptions): { // session cookie. Restrict scrape access at the reverse proxy / // firewall layer (see help doc). Disabled by // provider.metrics.enabled=false in config.yaml. - const appConfig = (() => { - try { return opts.configManager?.getConfig() ?? null; } catch { return null; } - })(); - const metricsCfg = appConfig?.provider?.metrics; - let workerMetrics: WorkerMetrics | null = null; - // Phase 3c: hoist the promRegistry handle out of the metrics-enabled - // block so the same-process gateway mount below can register its - // gateway_* counters into the same /metrics endpoint (one scrape - // serves both worker and gateway). Null when metrics are disabled. - let sharedPromRegistry: import('prom-client').Registry | null = null; - if (metricsCfg?.enabled !== false) { - const prefix = metricsCfg?.prefix ?? 'aao_worker'; - sharedPromRegistry = createWorkerRegistry(prefix); - workerMetrics = createWorkerMetrics(sharedPromRegistry, prefix); - // Phase 3b post-review: gate /metrics on (1) bearer token if set, - // (2) otherwise client-IP allowlist (default localhost). Labels - // like `worker_id` / `backend_id` would leak otherwise. - const metricsAuth = { - bearerToken: metricsCfg?.bearerToken, - allowedHosts: metricsCfg?.allowedHosts, - }; - app.get('/metrics', createMetricsHandler(sharedPromRegistry, metricsAuth)); - const authMode = metricsAuth.bearerToken - ? 'bearer' - : `ip-allowlist=${(metricsAuth.allowedHosts ?? ['127.0.0.1', '::1', 'localhost']).join(',')}`; - logger.info(`[bridge] worker metrics enabled prefix=${prefix} auth=${authMode}`); - } else { - logger.info('[bridge] worker metrics disabled (provider.metrics.enabled=false)'); - } + const { workerMetrics, sharedPromRegistry } = setupMetricsSubsystem(app, opts.configManager); // JSON body parser for Config/Pieces/Memory/Reflection API endpoints app.use('/api/config', express.json()); @@ -215,47 +172,10 @@ export function createCoreServer(opts: CoreServerOptions): { app.use('/api/local/memory', express.json()); app.use('/api/local/reflection', express.json()); - // === Auth setup === - // Auth activates when a provider is COMPLETELY configured (clientId + - // clientSecret + callbackUrl, plus baseUrl for Gitea). - // - // FAIL CLOSED on a partial config: if the operator clearly INTENDED auth - // (a provider has a client_id) but it's incomplete (typo'd / missing - // secret/callback), we must NOT silently fall back to no-auth — that would - // fail OPEN and expose /api/local, /api/config, etc. without authentication. - // Refuse to start instead, with a clear message. (A bare `auth` block with no - // client_id at all is treated as genuine no-auth mode.) - const _authProviders = opts.authConfig?.providers; - const authUsable = - isProviderConfigured(_authProviders?.google, 'google') || - isProviderConfigured(_authProviders?.gitea, 'gitea'); - // "Intended" = ANY OAuth field is present on a provider (not just client_id). - // Saving only client_secret/callback_url/base_url, or clearing client_id by - // mistake, must still fail closed rather than drop to no-auth. - const _hasAnyField = (p?: { clientId?: string; clientSecret?: string; callbackUrl?: string; baseUrl?: string }) => - !!(p?.clientId || p?.clientSecret || p?.callbackUrl || p?.baseUrl); - const authIntended = _hasAnyField(_authProviders?.google) || _hasAnyField(_authProviders?.gitea); - // Local email+password is a first-class auth mode: when enabled, auth is - // active even without any OAuth provider. - const localEnabled = !!opts.authConfig && isLocalEnabled(opts.authConfig); - // Fail closed on a partial OAuth config ONLY when it would otherwise drop to - // no-auth. If local auth is on, auth is already active (no fail-open), so a - // half-configured OAuth provider just stays inactive rather than aborting boot. - if (authIntended && !authUsable && !localEnabled) { - throw new Error( - '[auth] auth is partially configured: a provider has a client_id but is missing ' + - 'client_secret / callback_url (Gitea also needs base_url). Refusing to start in an ' + - 'insecure no-auth state — complete the provider config or remove it from config.yaml.', - ); - } - const authActive = authUsable || localEnabled; - if (!authActive) { - // No-auth single-user mode: per-user rows are owned by the synthetic - // 'local' id, and several tables FK to users(id) (ssh_user_deks, - // browser_session_profiles, …). Seed the 'local' user row so those inserts - // succeed — without it, creating an SSH connection failed with create_failed. - repo.ensureLocalUser(); - } + // === Auth setup === (see auth-subsystem.ts) + // Fail-closed activation decision: throws on a partially configured OAuth + // provider; seeds the synthetic 'local' user in no-auth mode. + const { authActive, localEnabled } = resolveAuthActivation(repo, opts.authConfig); let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined; // Resolve server config ONCE here with the real listen port (threaded in via @@ -277,135 +197,24 @@ export function createCoreServer(opts: CoreServerOptions): { hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear'; if (authActive) { - // Idempotently seed the shared `local` system admin (id='local', the same - // owner the no-auth path uses) so an existing single-user / no-auth - // deployment gains a login mid-stream and keeps its `local`-owned data. - const bootstrap = opts.authConfig?.local?.bootstrapAdmin; - if (localEnabled && bootstrap?.email && bootstrap?.password) { - repo.upsertLocalSystemAdmin({ email: bootstrap.email, password: bootstrap.password }); - logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`); - } - - // Compose effective secureCookie: native TLS termination also requires - // the secure flag on session cookies, even when no upstream proxy is - // present. IMPORTANT: trust-proxy (line ~191) stays keyed on the - // ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it. - const effectiveSecureCookie = computeEffectiveSecureCookie( - !!opts.authConfig?.secureCookie, - serverCfg.tls.enabled, - ); - const authConfigForSetup = opts.authConfig - ? { ...opts.authConfig, secureCookie: effectiveSecureCookie } - : opts.authConfig; - - const auth = setupAuth( + // Session/passport middleware + /auth routes + /api/* guards + // (see auth-subsystem.ts — registered here, BEFORE all later mounts). + authenticateUpgrade = mountAuthPipeline(app, { repo, - authConfigForSetup!, - () => { - const b = resolveBranding(opts.configManager); - return { appName: b.appName, loginPageTitle: b.loginPageTitle }; - }, - loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH, - ); - const { sessionMiddleware, passportInit, passportSession, authRouter } = auth; - authenticateUpgrade = auth.authenticateUpgrade; - - // Global session + passport middleware (BEFORE all routes) - app.use(sessionMiddleware); - app.use(passportInit); - app.use(passportSession); - - // Auth routes (unauthenticated access allowed) - app.use('/auth', authRouter); - - // /api/auth/me endpoint - app.get('/api/auth/me', requireAuth, (req: Request, res: Response) => { - res.json(req.user); + authConfig: opts.authConfig, + configManager: opts.configManager, + localEnabled, + tlsEnabled: serverCfg.tls.enabled, }); - - // Self-service password change for local accounts (see auth.ts). - app.post('/api/auth/password', requireAuth, express.json(), buildChangePasswordHandler(repo)); - - // Protect all API routes (except /api/version and /health) - app.use('/api/local', requireAuth); - app.use('/api/repos', requireAuth); - // /api/workers exposes endpoint URLs + proxy backend probing. Without - // auth, unauthenticated callers could (a) enumerate worker endpoints - // and (b) trigger upstream `/v1/models` fetches against any - // attacker-influenced endpoint (SSRF amplifier). Gate behind requireAuth - // — admin-only is unnecessary because the responses already strip - // sensitive fields (apiKey), but anonymous access must be blocked. - app.use('/api/workers', requireAuth); - - // Admin-only routes - app.use('/api/config', requireAdmin); - // /api/pieces: any authenticated user can GET (visibility-filtered); - // per-piece write authz (built-in/global-custom → admin, user-custom → owner) - // is enforced inside pieces-api.ts handlers. - app.use('/api/pieces', requireAuth); - app.use('/api/usage', requireAuth); - // 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは - // Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。 - app.use('/api/calendar', requireAuth); - // Scheduled tasks: any authenticated user can create/list (visibility-filtered). - // PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14). - app.use('/api/scheduled-tasks', requireAuth); } // Admin user management API (always mounted; protected by requireAdmin when auth is active) app.use('/api/admin', express.json()); mountAdminApi(app, repo, authActive, loadConfig()?.userFolderRoot ?? './data/users'); - // AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys. - // Enabled regardless of gateway.enabled so an admin can prep keys - // before flipping the gateway on. - // - // SECURITY: this endpoint mints `sk-aao-*` bearer tokens that grant - // access to the LLM gateway. Unlike the generic admin-api (which - // exposes user-management read paths that are safe to surface in - // auth-disabled local dev), key issuance has direct production - // impact. If we mounted it without `requireAdmin` when auth is off, - // any caller reaching the server could POST to /api/admin/gateway/keys - // and walk away with a valid gateway bearer. Refuse to mount instead - // — operators who want key management MUST configure auth.providers. - if (authActive) { - app.use( - '/api/admin/gateway/keys', - express.json({ limit: '4kb' }), - requireAdmin, - createAdminGatewayApi({ - repo, - // Already gated above; pass a passthrough so the router doesn't - // try to double-guard (which would just be a no-op anyway). - requireAdmin: (_req, _res, next) => next(), - getUserId: (req) => { - const u = (req as import('express').Request & { - user?: { id?: string }; - }).user; - return u?.id ?? null; - }, - // Phase 3a F4: when the admin server runs in the same process - // as the gateway, gateway/bootstrap.ts hangs the shared cache - // off the Repository so mutations invalidate live cache state. - // Cross-process setups omit this; the cache's 5s TTL bounds - // the worst-case stale window. - keyCache: (repo as unknown as { __gatewayKeyCache?: import('../gateway/key-cache.js').KeyCache }) - .__gatewayKeyCache, - // Phase 3b post-review: same-process gateway hangs its metrics - // handle on the Repository so admin mutations can drop the - // per-key gauge labels (revoke/rotate/delete). No-op in - // cross-process deploys (the gateway process owns its own - // registry there). - gatewayMetrics: (repo as unknown as { __gatewayMetrics?: import('../metrics/gateway-metrics.js').GatewayMetrics }) - .__gatewayMetrics, - }), - ); - } else { - logger.warn( - '[admin-gateway] /api/admin/gateway/keys NOT mounted (auth disabled). ' + - 'Configure auth.providers in config.yaml to enable virtual key management.', - ); - } + // AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys + // (see admin-gateway-keys-mount.ts — refuses to mount when auth is off). + mountAdminGatewayKeysApi(app, { repo, authActive }); // /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active) mountUsersApi(app, repo, authActive); @@ -422,26 +231,8 @@ export function createCoreServer(opts: CoreServerOptions): { // space-api と同じワークスペース解決(loadConfig().worktreeDir)を使う。 mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees'); - // Redirect root to UI - app.get('/', (_req: Request, res: Response) => { - res.redirect('/ui'); - }); - - const uiDistPath = resolve(join(__dirnameServer, '../../ui/dist')); - if (existsSync(uiDistPath)) { - app.use('/ui', express.static(uiDistPath)); - app.get('/ui/*', (_req, res) => { - res.sendFile(join(uiDistPath, 'index.html')); - }); - // Headless test-harness page for workspace-app E2E (Task 5/6). - // Must be registered BEFORE any SPA catch-all and BEFORE space-api routes - // so that the app-harness auth middleware (mounted below) sees the request. - // The harness JS/CSS chunks are served by the existing /ui static mount - // because Vite builds them with base: '/ui/' → /ui/assets/… paths. - app.get('/app-harness', (_req, res) => { - res.sendFile(join(uiDistPath, 'app-harness.html')); - }); - } + // Root redirect + UI static hosting + app-harness page (see ui-static.ts). + mountUiStatic(app); // Version + repos info endpoints (see meta-api.ts). Mounted here to preserve // ordering relative to the `/api/repos` requireAuth guard registered above. @@ -486,6 +277,8 @@ export function createCoreServer(opts: CoreServerOptions): { getMaxUploadMb: opts.configManager ? () => opts.configManager!.getConfig().tools?.taskUploadMaxSizeMb ?? 50 : () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50, + // Package-request approvals install into the task's space overlay. + getPythonPackagesConfig: () => loadConfig().pythonPackages, }); // --- Local files API --- @@ -498,75 +291,14 @@ export function createCoreServer(opts: CoreServerOptions): { // 横断(全スペース)カレンダー: GET /api/calendar/cross app.use('/api/calendar', createCrossCalendarApi({ repo, authActive })); - // --- A2A OAuth2 Authorization Server --- - const a2aCfg = (loadConfig() as any).a2a ?? {}; - if (a2aCfg.enabled) { - if (!authActive) { - logger.warn('[a2a-oidc] a2a.enabled but auth is not active; consent flow requires login and will reject all requests'); - } - try { - const sessionSecretPath = loadConfig()?.secrets?.sessionSecretPath ?? DEFAULT_SESSION_SECRET_PATH; - const cookieKeys = [readFileSync(sessionSecretPath, 'utf-8').trim()]; - const secretsDir = dirname(resolve(sessionSecretPath)); - const provider = createA2aOidcProvider({ - repo, - secretsDir, - issuer: a2aCfg.issuer, - resourceAudience: a2aCfg.resourceAudience, - cookieKeys, - }); - mountA2aOidc(app, provider, { repo, resourceAudience: a2aCfg.resourceAudience }); - app.use('/api/admin/a2a/clients', express.json(), requireAdmin, createA2aClientsAdminRouter(repo, authActive)); - // Agent Card ルータ (base card = public, extended card = self-authenticates via Bearer) - // baseUrl は resourceAudience の origin から導く(例: https://maestro.example.com/a2a → https://maestro.example.com) - // version は '1.0.0' 固定(生成済みバージョンファイルが存在しないため定数を使用) - const a2aBaseUrl = (() => { - try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); } - })(); - app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' })); - // JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由) - // /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。 - mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' }); - // bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では - // lease が必要になる(将来課題)。 - const a2aReconciler = new A2aTaskReconciler({ repo }); - a2aReconciler.start(); - registerShutdownHook('a2a-reconciler', async () => { a2aReconciler.stop(); }); - logger.info('[a2a] task reconciler started'); - // スペース A2A スキル許可リスト管理 API (/api/local は requireAuth 配下) - app.use('/api/local/spaces', createSpaceA2aAdminRouter(repo, authActive)); - // A2A 委任一覧・取消 API - // /api/local は L329 で requireAuth 済みのため追加ゲート不要 - app.use('/api/local/a2a/delegations', express.json(), createUserDelegationsRouter(repo, authActive)); - app.use('/api/admin/a2a/delegations', express.json(), requireAdmin, createAdminDelegationsRouter(repo, authActive)); - logger.info('[a2a-oidc] a2a authorization server enabled'); - } catch (err) { - logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`); - } - } + // --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) --- + setupA2aSubsystem(app, { repo, authActive }); // --- Subtask files API (listing MUST come before wildcard) --- mountSubtaskFilesApi(app, repo); - // --- Job detail API --- - // Gate on viewer: getJob returns null for jobs the caller cannot see (Task 10 + 16). - // When auth is inactive, req.user is undefined → repository falls back to 1=1 (no filter). - const jobDetailHandlers: express.RequestHandler[] = []; - if (authActive) jobDetailHandlers.push(requireAuth); - jobDetailHandlers.push(async (req: Request, res: Response) => { - try { - const viewer = (req.user as Express.User | undefined) ?? undefined; - const job = await repo.getJob(req.params.jobId, viewer ? { viewer } : undefined); - if (!job) { - res.status(404).json({ error: 'Job not found' }); - return; - } - res.json(job); - } catch { - res.status(500).json({ error: 'Failed to fetch job' }); - } - }); - app.get('/api/jobs/:jobId', ...jobDetailHandlers); + // --- Job detail API (see jobs-api.ts) --- + mountJobsApi(app, { repo, authActive }); // NOTE: bridge `/health` (`{status:'ok'}`) is intentionally registered // LATER — see the "bridge /health fallback" block below the gateway @@ -575,58 +307,14 @@ export function createCoreServer(opts: CoreServerOptions): { // able to dispatch `/health` to the gateway sub-app when running // (LiteLLM-shape JSON — CRITICAL-3 fix). - // === Browser setup wizard (fresh no-auth install onboarding) === - // Token lifecycle + endpoints. The no-auth `/api/config` mutation gate - // (Codex P1 #3) must register BEFORE mountConfigApi so it intercepts - // PUT/POST while a setup window is open. See setup-api.ts. + // === Browser setup wizard (fresh no-auth install onboarding) + Config API === + // (see setup-config-mount.ts) if (opts.configManager) { - const setupDataDir = join(process.cwd(), 'data'); - const setupListenPort = - opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port); - // Synchronously seed the gate state to 'pending' (fail-closed for auth - // writes) BEFORE the async lifecycle below resolves, so a request arriving - // in the boot window can't slip an auth bootstrap through (Codex P1 #2). - const setupGateState = newNoAuthConfigGateState(); - - // Token lifecycle (needs the async setup-lib import). Created only on a - // fresh no-auth install (needs-setup); cleared otherwise so existing - // configured deployments keep their prior open behavior. On failure the - // gate stays 'pending' (fail-closed), never silently open. - void (async () => { - try { - if (authActive) { - clearSetupToken(setupDataDir); - return; - } - const { isLlmConfigured } = await import('../../scripts/setup-lib.mjs'); - const workers = opts.configManager?.getConfig().provider?.workers ?? []; - if (!isLlmConfigured(workers)) { - const token = ensureSetupToken(setupDataDir); - setupGateState.mode = 'token-required'; - logger.info(`[setup] open the UI and enter this setup token: ${token}`); - logger.info('[setup] llm configured=false → wizard active'); - } else { - clearSetupToken(setupDataDir); - setupGateState.mode = 'open'; - logger.info('[setup] llm configured=true → wizard off'); - } - } catch (e) { - logger.warn(`[setup] token lifecycle failed: ${String(e)}`); - } - })(); - - // No-auth /api/config auth-write gate (only mounted in no-auth mode). - if (!authActive) { - app.use('/api/config', buildNoAuthConfigGate(setupDataDir, setupGateState)); - } - - mountSetupApi(app, opts.configManager, { + mountSetupAndConfigApis(app, { + configManager: opts.configManager, authActive, - listenPort: setupListenPort, - dataDir: setupDataDir, + listenPort: opts.listenPort, }); - - mountConfigApi(app, opts.configManager); } // Branding 公開 API (GET は認証不要)。アップロード系は admin のみ。 @@ -800,75 +488,17 @@ export function createCoreServer(opts: CoreServerOptions): { backendStatusRegistry, })); - // Phase 3c — same-process AAO Gateway mount. Always installs the - // dynamic 404 gate; the gateway sub-app only comes alive when - // `gateway.enabled: true` in config.yaml (or after an admin flips it - // ON via Settings → Gateway Server). Pure no-op when no ConfigManager - // is available (tests / scripts run without one). - let gatewayMount: GatewayMountHandle | null = null; - if (opts.configManager) { - gatewayMount = mountGateway({ - app, - configManager: opts.configManager, - repo, - // Per CRITICAL-2: the gateway now owns its own BackendStatusRegistry - // over gateway.backends[] rather than reusing the worker registry - // (which probes provider.workers[] — different IDs). Same-host - // double-probe is intentional and cheap. - // Reuse the worker's prom-client registry so gateway counters - // appear on the same /metrics endpoint (no port collision). - promRegistry: sharedPromRegistry, - metricsPrefix: 'aao_gateway', - }); - // Apply the boot-time config so a server starting with - // `gateway.enabled: true` brings the gateway up immediately — - // no need to re-save config from the UI. - const bootGateway = readGatewayConfig(opts.configManager.getConfig()); - gatewayMount.applyConfig(bootGateway).catch((e) => { - logger.warn(`[bridge-gateway] initial applyConfig threw: ${e instanceof Error ? e.message : String(e)}`); - }); - // Drain in-flight gateway streams on process shutdown so SIGTERM - // doesn't strand SSE clients with a half-written response. - registerShutdownHook('bridge-gateway', async () => { - try { await gatewayMount!.stop(); } catch { /* noop */ } - }); - } else { - logger.info('[bridge-gateway] not mounted (no ConfigManager — hot reload unavailable)'); - } - - // CRITICAL-3 fix: bridge `/health` fallback. Registered AFTER the - // gateway gate so when gateway.enabled=true the gate dispatches - // `/health` into the gateway sub-app (LiteLLM-compat - // `healthy_endpoints` / `unhealthy_endpoints` JSON shape). When the - // gateway is off, the gate falls through to here and the bridge - // answers with the legacy `{status:'ok'}` shape ops scripts rely on. - app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok' }); + // Phase 3c — same-process AAO Gateway mount + bridge /health fallback + + // admin gateway status endpoint (see bridge-gateway-mount.ts; the + // gateway gate must register BEFORE /health — order preserved inside). + const gatewayMount: GatewayMountHandle | null = mountBridgeGateway(app, { + repo, + configManager: opts.configManager, + sharedPromRegistry, + authActive, + listenPort: opts.listenPort, }); - // Admin status endpoint for the Gateway Server UI. Read-only — the - // form drives state changes through PUT /api/config (the - // config-changed listener inside mountGateway picks them up). - // Requires admin when auth is active; in auth-off dev mode the - // endpoint is open like the rest of /api/admin/* health/status reads. - { - // Prefer the explicit listenPort threaded through CoreServerOptions - // (startCoreServer always sets it) over the PORT env-var guess so - // the status endpoint matches what the bridge actually bound. - // env-var fallback is kept for callers that bypass startCoreServer. - const actualPort = opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port); - const statusRouter = createAdminGatewayStatusRouter({ - mount: gatewayMount, - configManager: opts.configManager ?? null, - workerPort: actualPort, - }); - if (authActive) { - app.use('/api/admin/gateway/status', requireAdmin, statusRouter); - } else { - app.use('/api/admin/gateway/status', statusRouter); - } - } - // Wire user-folder tool deps so RunUserScript / ListUserAssets can decrypt sessions. setUserFolderToolDeps({ sessRepo, @@ -895,37 +525,17 @@ export function createCoreServer(opts: CoreServerOptions): { // Wire app-docs tool deps so GetMyOrchestratorState can introspect DB. setAppDocsDeps({ db: repo.getDb(), userFolderRoot }); - // noVNC static files - app.use('/novnc', createNovncRouter()); - - // CAPTCHA Pool は admin、Task Session はタスク visibility で認可。 - // novnc-proxy は Repository を直接知らずに済むよう、判定ロジックは - // ここでクロージャに包んで渡す。 - const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer = async (session, user) => { - if (session.kind === 'pool') { - return user.role === 'admin'; - } - if (session.kind === 'task' && session.taskId) { - const taskIdNum = Number(session.taskId); - if (!Number.isFinite(taskIdNum)) return false; - const task = await repo.getLocalTask(taskIdNum); - if (!task) return false; - const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false; - return canUserSeeTask(user, task, spaceAccessible); - } - // legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin - const isOwner = session.userId === user.id; - return isOwner || user.role === 'admin'; - }; + // noVNC static files + session authorizer (see novnc-mount.ts) + mountNovncStatic(app); + const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer = + buildNovncSessionAuthorizer(repo); return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg }; } -/** Cookie `secure` must be set whenever the user-facing scheme is https — - * via an upstream TLS proxy (secureCookie) OR native TLS termination. */ -export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean { - return secureCookie || tlsEnabled; -} +// Definition moved to auth-subsystem.ts; re-exported here so existing +// importers of bridge/server.js keep working unchanged. +export { computeEffectiveSecureCookie } from './auth-subsystem.js'; /** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a * reverse proxy is also terminating TLS → double-TLS misconfiguration. */ @@ -1004,12 +614,15 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v opts.workerManager.setWorkerMetrics(workerMetrics); } const finalApp = finalizeServer(app); - // Safe-by-default bind: loopback unless HOST is set explicitly. The agent API - // includes the Bash tool, so an unauthenticated instance reachable on the LAN - // is effectively unauthenticated RCE. Bare-metal / systemd installs inherit - // this loopback default; the Docker image sets HOST=0.0.0.0 (it must bind all - // interfaces inside the container) and is protected by the host-side - // 127.0.0.1:9876 port mapping instead. + // Bind host: loopback unless HOST is set explicitly. The agent API includes + // the Bash tool, so an unauthenticated instance reachable on the LAN is + // effectively unauthenticated RCE. Bare-metal / systemd installs inherit this + // loopback default. The Docker image sets HOST=0.0.0.0 (it must bind all + // interfaces inside the container) and docker-compose.yml publishes the port + // on all host interfaces by default, so a Docker deployment is LAN-reachable + // out of the box — enabling auth/TLS before exposing it is the operator's + // responsibility (see docs/docker.md, SECURITY.md). Restore local-only by + // pinning the compose mapping back to "127.0.0.1:9876:9876". const host = process.env['HOST'] ?? '127.0.0.1'; const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost'; // Use the config snapshot already resolved in createCoreServer (same port, diff --git a/src/bridge/setup-config-mount.ts b/src/bridge/setup-config-mount.ts new file mode 100644 index 0000000..17509bb --- /dev/null +++ b/src/bridge/setup-config-mount.ts @@ -0,0 +1,81 @@ +import express from 'express'; +import { join } from 'path'; +import { logger } from '../logger.js'; +import { loadConfig } from '../config.js'; +import { ConfigManager } from '../config-manager.js'; +import { mountConfigApi } from './config-api.js'; +import { + mountSetupApi, + buildNoAuthConfigGate, + newNoAuthConfigGateState, + ensureSetupToken, + clearSetupToken, +} from './setup-api.js'; +import { resolveListenPort } from '../server/config.js'; + +/** + * Browser setup wizard (fresh no-auth install onboarding) + Config API, + * extracted verbatim from createCoreServer (bridge/server.ts). + * + * Token lifecycle + endpoints. The no-auth `/api/config` mutation gate + * (Codex P1 #3) must register BEFORE mountConfigApi so it intercepts + * PUT/POST while a setup window is open. See setup-api.ts. + */ +export function mountSetupAndConfigApis( + app: express.Application, + deps: { + configManager: ConfigManager; + authActive: boolean; + listenPort?: number; + }, +): void { + const { configManager, authActive, listenPort } = deps; + + const setupDataDir = join(process.cwd(), 'data'); + const setupListenPort = + listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port); + // Synchronously seed the gate state to 'pending' (fail-closed for auth + // writes) BEFORE the async lifecycle below resolves, so a request arriving + // in the boot window can't slip an auth bootstrap through (Codex P1 #2). + const setupGateState = newNoAuthConfigGateState(); + + // Token lifecycle (needs the async setup-lib import). Created only on a + // fresh no-auth install (needs-setup); cleared otherwise so existing + // configured deployments keep their prior open behavior. On failure the + // gate stays 'pending' (fail-closed), never silently open. + void (async () => { + try { + if (authActive) { + clearSetupToken(setupDataDir); + return; + } + const { isLlmConfigured } = await import('../../scripts/setup-lib.mjs'); + const workers = configManager.getConfig().provider?.workers ?? []; + if (!isLlmConfigured(workers)) { + const token = ensureSetupToken(setupDataDir); + setupGateState.mode = 'token-required'; + logger.info(`[setup] open the UI and enter this setup token: ${token}`); + logger.info('[setup] llm configured=false → wizard active'); + } else { + clearSetupToken(setupDataDir); + setupGateState.mode = 'open'; + logger.info('[setup] llm configured=true → wizard off'); + } + } catch (e) { + logger.warn(`[setup] token lifecycle failed: ${String(e)}`); + } + })(); + + // No-auth /api/config auth-write gate (only mounted in no-auth mode). + if (!authActive) { + app.use('/api/config', buildNoAuthConfigGate(setupDataDir, setupGateState)); + } + + mountSetupApi(app, configManager, { + authActive, + listenPort: setupListenPort, + dataDir: setupDataDir, + }); + + mountConfigApi(app, configManager); +} diff --git a/src/bridge/skills-api.test.ts b/src/bridge/skills-api.test.ts index d17b548..eacc724 100644 --- a/src/bridge/skills-api.test.ts +++ b/src/bridge/skills-api.test.ts @@ -330,6 +330,92 @@ describe('DELETE /api/skills/:name', () => { }); }); +// --------------------------------------------------------------------------- +// Folder/frontmatter name mismatch: the catalog lists skills by their +// frontmatter `name`, but a skill's folder (or flat file) can carry a +// different name (e.g. installed via InstallSkill with a mismatched name). +// DELETE/PUT must resolve such skills by their catalog name instead of +// reconstructing the path from it — otherwise they are listed but can never +// be deleted or edited (404 forever). +// --------------------------------------------------------------------------- + +describe('folder/frontmatter name mismatch', () => { + function addMismatchedUserSkill(userId: string, folderName: string, fmName: string): void { + const dir = join(userRoot, userId, 'skills', folderName); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), SKILL_MD(fmName)); + } + + it('DELETE removes a user skill whose folder name differs from its frontmatter name', async () => { + addMismatchedUserSkill('user-1', 'folder-name', 'display-name'); + const app = makeApp(makeCatalog(), { id: 'user-1' }); + + // Listed under the frontmatter name. + const list = await request(app).get('/api/skills?scope=user'); + expect(list.body.skills.map((s: { name: string }) => s.name)).toContain('display-name'); + + const res = await request(app).delete('/api/skills/display-name?scope=user'); + expect(res.status).toBe(200); + expect(existsSync(join(userRoot, 'user-1', 'skills', 'folder-name'))).toBe(false); + }); + + it('DELETE removes a system skill whose folder name differs from its frontmatter name (admin)', async () => { + const dir = join(systemDir, 'sys-folder'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), SKILL_MD('sys-display')); + + const res = await request(makeApp(makeCatalog(), { id: 'admin-1', role: 'admin' })) + .delete('/api/skills/sys-display?scope=system'); + expect(res.status).toBe(200); + expect(existsSync(dir)).toBe(false); + }); + + it('DELETE removes a flat-file skill whose file name differs from its frontmatter name', async () => { + const userSkillsDir = join(userRoot, 'user-1', 'skills'); + mkdirSync(userSkillsDir, { recursive: true }); + writeFileSync(join(userSkillsDir, 'odd-file.md'), SKILL_MD('flat-display')); + + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .delete('/api/skills/flat-display?scope=user'); + expect(res.status).toBe(200); + expect(existsSync(join(userSkillsDir, 'odd-file.md'))).toBe(false); + }); + + it('DELETE does not fall back to another scope: a user-only name is 404 under scope=system', async () => { + addMismatchedUserSkill('user-1', 'folder-name', 'display-name'); + const res = await request(makeApp(makeCatalog(), { id: 'admin-1', role: 'admin' })) + .delete('/api/skills/display-name?scope=system'); + expect(res.status).toBe(404); + expect(existsSync(join(userRoot, 'user-1', 'skills', 'folder-name'))).toBe(true); + }); + + it('POST rejects content whose frontmatter name differs from the skill name', async () => { + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .post('/api/skills') + .send({ name: 'param-name', scope: 'user', content: SKILL_MD('content-name') }); + expect(res.status).toBe(400); + expect(existsSync(join(userRoot, 'user-1', 'skills', 'param-name'))).toBe(false); + }); + + it('POST rejects content without a valid frontmatter name', async () => { + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .post('/api/skills') + .send({ name: 'no-fm', scope: 'user', content: '# heading only\nno frontmatter' }); + expect(res.status).toBe(400); + expect(existsSync(join(userRoot, 'user-1', 'skills', 'no-fm'))).toBe(false); + }); + + it('PUT updates a skill whose folder name differs from its frontmatter name', async () => { + addMismatchedUserSkill('user-1', 'folder-name', 'display-name'); + const res = await request(makeApp(makeCatalog(), { id: 'user-1' })) + .put('/api/skills/display-name?scope=user') + .send({ content: `---\nname: display-name\ndescription: updated\n---\n\nnew body` }); + expect(res.status).toBe(200); + const saved = readFileSync(join(userRoot, 'user-1', 'skills', 'folder-name', 'SKILL.md'), 'utf-8'); + expect(saved).toContain('new body'); + }); +}); + // --------------------------------------------------------------------------- // Spaces foundation: optional spaceId scoping (mirrors pieces-api) // - no spaceId → per-user skills dir (byte-identical regression) diff --git a/src/bridge/skills-api.ts b/src/bridge/skills-api.ts index b866997..4aeb3c6 100644 --- a/src/bridge/skills-api.ts +++ b/src/bridge/skills-api.ts @@ -1,9 +1,9 @@ import express, { type Application, type Request, type Response, type RequestHandler } from 'express'; import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync, lstatSync, renameSync, rmSync } from 'fs'; -import { join, relative } from 'path'; +import { join, relative, dirname } from 'path'; import { randomBytes } from 'crypto'; import type { SkillCatalog, SkillEntry } from '../engine/skills.js'; -import { VALID_SKILL_NAME } from '../engine/skills.js'; +import { VALID_SKILL_NAME, scanSkillsDir } from '../engine/skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../engine/skills-scanner.js'; import matter from 'gray-matter'; import { logger } from '../logger.js'; @@ -67,6 +67,34 @@ function isAdmin(req: Request): boolean { return user?.role === 'admin'; } +/** + * Locate a skill on disk by its catalog (display) name. The catalog lists + * skills by their SKILL.md frontmatter `name`, which can differ from the + * folder/file name (e.g. installed under a different name). Path-by-name is + * tried first (also covers folders without parseable frontmatter), then the + * directory is scanned for an entry whose frontmatter name matches. All + * returned paths are direct children of `baseDir` by construction. + */ +function resolveSkillPaths( + baseDir: string, + name: string, + source: 'system' | 'user', +): { dirPath: string | null; filePath: string } | null { + const dirPath = join(baseDir, name); + if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) { + return { dirPath, filePath: join(dirPath, 'SKILL.md') }; + } + const flatPath = join(baseDir, `${name}.md`); + if (existsSync(flatPath) && lstatSync(flatPath).isFile()) { + return { dirPath: null, filePath: flatPath }; + } + const entry = scanSkillsDir(baseDir, source).find(e => e.name === name); + if (entry) { + return { dirPath: entry.dirPath, filePath: entry.filePath }; + } + return null; +} + /** * Recursively list files in a directory, skipping symlinks. * Returns paths relative to `baseDir`. @@ -313,6 +341,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v // System scope requires admin if (scope === 'system' && !isAdmin(req)) { + logger.warn(`[skills-api] create rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`); res.status(403).json({ error: 'Only admins can create system skills' }); return; } @@ -321,6 +350,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v const skillScope = await resolveSkillScope(req); if ('forbidden' in skillScope && skillScope.forbidden) { + logger.warn(`[skills-api] create rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`); res.status(404).json({ error: 'Space not found' }); return; } @@ -334,10 +364,35 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v const destDirPath = join(destDir, name); const destFlatPath = join(destDir, `${name}.md`); if (existsSync(destDirPath) || existsSync(destFlatPath)) { + logger.warn(`[skills-api] create rejected skill=${name} scope=${scope} actor=${userId} reason=conflict`); res.status(409).json({ error: 'Skill already exists' }); return; } + // The catalog lists skills by their frontmatter `name`. Require valid + // frontmatter matching the requested name — otherwise the skill would be + // stored under one name but listed under another (or not listed at all), + // making it impossible to manage from the UI. + let fmName = ''; + try { + const parsed = matter(content); + fmName = typeof parsed.data?.name === 'string' ? parsed.data.name : ''; + } catch { + fmName = ''; + } + if (!fmName || !VALID_SKILL_NAME.test(fmName)) { + res.status(400).json({ + error: 'Content must begin with YAML frontmatter containing a valid "name" (lowercase alphanumeric, hyphens, underscores) — otherwise the skill will not appear in the skill list.', + }); + return; + } + if (fmName !== name) { + res.status(400).json({ + error: `Frontmatter name "${fmName}" must match the skill name "${name}".`, + }); + return; + } + // Scan content before writing const findings = scanSkillContent(content); const severity = maxSeverity(findings); @@ -364,6 +419,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v logger.info(`[skills-api] created skill=${name} scope=${scope} actor=${actor} severity=${severity}`); res.status(201).json({ name, scope, severity, findings }); } catch (e) { + logger.error(`[skills-api] create failed skill=${req.body?.name} actor=${getUserId(req)} error=${e}`); res.status(500).json({ error: `Failed to create skill: ${e}` }); } }); @@ -388,6 +444,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v // System scope requires admin if (scope === 'system' && !isAdmin(req)) { + logger.warn(`[skills-api] update rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`); res.status(403).json({ error: 'Only admins can edit system skills' }); return; } @@ -408,6 +465,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v const skillScope = await resolveSkillScope(req); if ('forbidden' in skillScope && skillScope.forbidden) { + logger.warn(`[skills-api] update rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`); res.status(404).json({ error: 'Space not found' }); return; } @@ -416,18 +474,13 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v ? skillCatalog.getSystemDir() : userSkillDirFor(skillScope, userId); - // Find the skill file: either flat file or directory with SKILL.md - let targetPath: string | null = null; - const flatPath = join(baseDir, `${name}.md`); - const dirSkillPath = join(baseDir, name, 'SKILL.md'); - - if (existsSync(dirSkillPath)) { - targetPath = dirSkillPath; - } else if (existsSync(flatPath)) { - targetPath = flatPath; - } + // Find the SKILL.md to update: by folder/file name, falling back to a + // frontmatter-name scan (folder and display name can differ). + const resolved = resolveSkillPaths(baseDir, name, scope as 'system' | 'user'); + const targetPath = resolved && existsSync(resolved.filePath) ? resolved.filePath : null; if (!targetPath) { + logger.warn(`[skills-api] update rejected skill=${name} scope=${scope} actor=${userId} reason=not_found`); res.status(404).json({ error: 'Skill not found' }); return; } @@ -461,7 +514,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v const severity = maxSeverity(findings); // Atomic write: tmpfile in same directory as target → rename - const targetDir = targetPath === dirSkillPath ? join(baseDir, name) : baseDir; + const targetDir = dirname(targetPath); const tmpPath = join(targetDir, `.tmp-${randomBytes(8).toString('hex')}.md`); writeFileSync(tmpPath, content, 'utf-8'); renameSync(tmpPath, targetPath); @@ -482,6 +535,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v logger.info(`[skills-api] updated skill=${name} scope=${scope} actor=${actor} severity=${severity}`); res.json({ ok: true, severity, findings }); } catch (e) { + logger.error(`[skills-api] update failed skill=${name} actor=${getUserId(req)} error=${e}`); res.status(500).json({ error: `Failed to update skill: ${e}` }); } }); @@ -506,6 +560,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v // System scope requires admin if (scope === 'system' && !isAdmin(req)) { + logger.warn(`[skills-api] delete rejected skill=${name} scope=system actor=${getUserId(req)} reason=not_admin`); res.status(403).json({ error: 'Only admins can delete system skills' }); return; } @@ -515,6 +570,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v const skillScope = await resolveSkillScope(req); if ('forbidden' in skillScope && skillScope.forbidden) { + logger.warn(`[skills-api] delete rejected skill=${name} scope=${scope} actor=${userId} reason=space_not_found`); res.status(404).json({ error: 'Space not found' }); return; } @@ -523,24 +579,21 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v ? skillCatalog.getSystemDir() : userSkillDirFor(skillScope, userId); - // Find the skill: directory or flat file - const dirPath = join(baseDir, name); - const flatPath = join(baseDir, `${name}.md`); - let deleted = false; - - if (existsSync(dirPath) && lstatSync(dirPath).isDirectory()) { - rmSync(dirPath, { recursive: true, force: true }); - deleted = true; - } else if (existsSync(flatPath) && lstatSync(flatPath).isFile()) { - unlinkSync(flatPath); - deleted = true; - } - - if (!deleted) { + // Find the skill: by folder/file name, falling back to a frontmatter-name + // scan (folder and display name can differ — see resolveSkillPaths). + const resolved = resolveSkillPaths(baseDir, name, scope as 'system' | 'user'); + if (!resolved) { + logger.warn(`[skills-api] delete rejected skill=${name} scope=${scope} actor=${userId} reason=not_found`); res.status(404).json({ error: 'Skill not found' }); return; } + if (resolved.dirPath) { + rmSync(resolved.dirPath, { recursive: true, force: true }); + } else { + unlinkSync(resolved.filePath); + } + // Invalidate cache if (scope === 'system') { skillCatalog.refreshSystem(); @@ -557,6 +610,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v logger.info(`[skills-api] deleted skill=${name} scope=${scope} actor=${actor}`); res.json({ ok: true }); } catch (e) { + logger.error(`[skills-api] delete failed skill=${name} actor=${getUserId(req)} error=${e}`); res.status(500).json({ error: `Failed to delete skill: ${e}` }); } }); diff --git a/src/bridge/space-python-packages-api.ts b/src/bridge/space-python-packages-api.ts index 758e207..d90b289 100644 --- a/src/bridge/space-python-packages-api.ts +++ b/src/bridge/space-python-packages-api.ts @@ -13,6 +13,12 @@ import { } from '../engine/python-packages.js'; import { isBwrapAvailable } from '../engine/tools/sandbox.js'; import type { SpaceApiDeps } from './space-api.js'; +import { + readState, + withSpaceLock, + addPackageToSpace, + type PersistedState, +} from './space-python-packages-service.js'; /** * Per-space Python package management (admin form entry point). @@ -27,37 +33,9 @@ import type { SpaceApiDeps } from './space-api.js'; * network-enabled bwrap with no workspace/secret access (see python-packages.ts). */ -interface PersistedPkg { - name: string; // PEP 503 normalized - spec: string; // canonical pip argv, e.g. "requests==2.32.3" - addedAt: string; -} -interface PersistedState { - packages: PersistedPkg[]; - lockHash?: string; - updatedAt?: string; -} - -function readState(json: string | null): PersistedState { - if (!json) return { packages: [] }; - try { - const v = JSON.parse(json); - if (v && Array.isArray(v.packages)) return v as PersistedState; - } catch { - // fall through - } - return { packages: [] }; -} - -// Per-space in-process mutex so two concurrent installs never race the overlay -// or the read-modify-write of the desired-state JSON. -const spaceLocks = new Map>(); -function withSpaceLock(spaceId: string, fn: () => Promise): Promise { - const prev = spaceLocks.get(spaceId) ?? Promise.resolve(); - const next = prev.catch(() => {}).then(fn); - spaceLocks.set(spaceId, next.catch(() => {})); - return next; -} +// PersistedState / readState / withSpaceLock / addPackageToSpace now live in +// space-python-packages-service.ts so the admin form and agent-approval paths +// share ONE mutex + ONE desired-state read-modify-write. export function registerSpacePythonPackagesRoutes(router: Router, deps: SpaceApiDeps): void { const { repo } = deps; @@ -120,40 +98,17 @@ export function registerSpacePythonPackagesRoutes(router: Router, deps: SpaceApi if (!pre.ok) return res.status(503).json({ error: pre.reason }); try { - const result = await withSpaceLock(space.id, async () => { - const state = readState(repo.getSpacePythonPackages(space.id)); - if (state.packages.some((p) => p.name === parsed.normalizedName)) { - return { conflict: true as const }; - } - if (state.packages.length + 1 > c.maxPackagesPerSpace) { - return { tooMany: true as const }; - } - // Rebuild the FULL set (existing + new) into a fresh overlay. - const specs: ParsedSpec[] = [ - ...state.packages.map((p) => parseAndValidateSpec(p.spec)), - parsed, - ]; - const install = await installSpacePackages({ - pkgRoot: c.dir, - spaceKey: spaceKeyFor(space.id, space.ownerId), - specs, - cfg: c, - bwrapAvailable: await isBwrapAvailable(), - }); - if (!install.ok) return { installError: install.error ?? 'install failed' }; - const next: PersistedState = { - packages: [ - ...state.packages, - { name: parsed.normalizedName, spec: parsed.spec, addedAt: new Date().toISOString() }, - ], - lockHash: install.lockHash, - updatedAt: new Date().toISOString(), - }; - repo.setSpacePythonPackages(space.id, JSON.stringify(next)); - return { ok: true as const, state: next }; + const result = await addPackageToSpace({ + repo, + cfg: c, + spaceId: space.id, + ownerId: space.ownerId, + parsed, + bwrapAvailable: await isBwrapAvailable(), }); if ('conflict' in result) return res.status(409).json({ error: `already installed: ${parsed.normalizedName}` }); + if ('versionConflict' in result) return res.status(409).json({ error: `already pinned to a different version: ${result.versionConflict}. Remove it first.` }); if ('tooMany' in result) return res.status(400).json({ error: `too many packages (max ${c.maxPackagesPerSpace})` }); if ('installError' in result) return res.status(422).json({ error: result.installError }); logger.info(`[space-api] python-package added space=${space.id} pkg=${parsed.spec}`); diff --git a/src/bridge/space-python-packages-service.test.ts b/src/bridge/space-python-packages-service.test.ts new file mode 100644 index 0000000..ca0961e --- /dev/null +++ b/src/bridge/space-python-packages-service.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { Repository } from '../db/repository.js'; +import { addPackageToSpace } from './space-python-packages-service.js'; +import { resolvePythonPackagesConfig, parseAndValidateSpec } from '../engine/python-packages.js'; + +let repo: Repository; +let dir: string; +const cfg = resolvePythonPackagesConfig({ enabled: true, maxPackagesPerSpace: 30 }); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pkgsvc-')); + repo = new Repository(join(dir, `${randomUUID()}.db`)); +}); +afterEach(() => { + repo.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +async function seedSpaceWith(spec: string): Promise { + const owner = repo.createUser({ email: 'o@e.com', name: 'O', role: 'user', status: 'active' }); + const space = await repo.createSpace({ kind: 'case', title: 'T', ownerId: owner.id, visibility: 'private' }); + const p = parseAndValidateSpec(spec); + repo.setSpacePythonPackages(space.id, JSON.stringify({ packages: [{ name: p.normalizedName, spec: p.spec, addedAt: 'x' }] })); + return space.id; +} + +describe('addPackageToSpace conflict detection (no install path)', () => { + it('same name + same spec → conflict (idempotent, no install attempted)', async () => { + const spaceId = await seedSpaceWith('requests==2.32.3'); + // bwrapAvailable:false would fail-close a real install; but conflict is + // detected BEFORE install, so this stays hermetic. + const out = await addPackageToSpace({ + repo, cfg, spaceId, ownerId: null, + parsed: parseAndValidateSpec('requests==2.32.3'), bwrapAvailable: false, + }); + expect(out).toEqual({ conflict: true }); + }); + + it('same name + DIFFERENT spec → versionConflict with the existing spec', async () => { + const spaceId = await seedSpaceWith('httpx==0.27.0'); + const out = await addPackageToSpace({ + repo, cfg, spaceId, ownerId: null, + parsed: parseAndValidateSpec('httpx==0.28.0'), bwrapAvailable: false, + }); + expect(out).toEqual({ versionConflict: 'httpx==0.27.0' }); + }); +}); diff --git a/src/bridge/space-python-packages-service.ts b/src/bridge/space-python-packages-service.ts new file mode 100644 index 0000000..8903acf --- /dev/null +++ b/src/bridge/space-python-packages-service.ts @@ -0,0 +1,121 @@ +import type { Repository } from '../db/repository.js'; +import { + installSpacePackages, + parseAndValidateSpec, + spaceKeyFor, + type ParsedSpec, + type PythonPackagesConfig, +} from '../engine/python-packages.js'; + +/** + * Shared per-space Python package overlay service. + * + * Both entry points that add a package to a space MUST go through here so they + * share ONE module-level mutex (`spaceLocks`) and ONE desired-state read-modify- + * write path: + * - the admin form (space-python-packages-api.ts) + * - agent approvals (local-tasks-package-requests-api.ts, PR3) + * + * Sharing the mutex is load-bearing, not just DRY: a concurrent admin-add and + * agent-approve on the same space would otherwise race the content-addressed + * overlay build and the `spaces.python_packages` JSON. The persisted desired- + * state is the source of truth; each mutation rebuilds the whole overlay and + * atomically repoints `current` (see python-packages.ts). + */ + +export interface PersistedPkg { + name: string; // PEP 503 normalized + spec: string; // canonical pip argv, e.g. "requests==2.32.3" + addedAt: string; +} +export interface PersistedState { + packages: PersistedPkg[]; + lockHash?: string; + updatedAt?: string; +} + +export function readState(json: string | null): PersistedState { + if (!json) return { packages: [] }; + try { + const v = JSON.parse(json); + if (v && Array.isArray(v.packages)) return v as PersistedState; + } catch { + // fall through + } + return { packages: [] }; +} + +// Per-space in-process mutex so two concurrent installs never race the overlay +// or the read-modify-write of the desired-state JSON. Module-level so it is +// shared across every caller in this process. +const spaceLocks = new Map>(); +export function withSpaceLock(spaceId: string, fn: () => Promise): Promise { + const prev = spaceLocks.get(spaceId) ?? Promise.resolve(); + const next = prev.catch(() => {}).then(fn); + spaceLocks.set(spaceId, next.catch(() => {})); + return next; +} + +export type AddPackageOutcome = + | { ok: true; state: PersistedState } + | { conflict: true } + | { versionConflict: string } // same name, DIFFERENT spec already pinned (existing spec) + | { tooMany: true } + | { installError: string }; + +/** + * Add ONE already-validated package to a space's desired-state and rebuild the + * overlay under the shared per-space lock. Idempotent by name: an existing + * package returns `{ conflict: true }` without touching the overlay. + * + * Callers MUST have validated `parsed` (parseAndValidateSpec + assertNotShadowing) + * and checked the feature is enabled. This function does NOT re-authorize. + */ +export async function addPackageToSpace(params: { + repo: Repository; + cfg: PythonPackagesConfig; + spaceId: string; + ownerId: string | null | undefined; + parsed: ParsedSpec; + bwrapAvailable: boolean; +}): Promise { + const { repo, cfg, spaceId, ownerId, parsed, bwrapAvailable } = params; + return withSpaceLock(spaceId, async () => { + const state = readState(repo.getSpacePythonPackages(spaceId)); + const existing = state.packages.find((p) => p.name === parsed.normalizedName); + if (existing) { + // Same exact spec → idempotent no-op success. Same name but a DIFFERENT + // spec cannot be added (one version per package) — surface it distinctly + // so the caller doesn't falsely report the requested version as installed. + return existing.spec === parsed.spec + ? { conflict: true as const } + : { versionConflict: existing.spec }; + } + if (state.packages.length + 1 > cfg.maxPackagesPerSpace) { + return { tooMany: true as const }; + } + // Rebuild the FULL set (existing + new) into a fresh content-addressed overlay. + const specs: ParsedSpec[] = [ + ...state.packages.map((p) => parseAndValidateSpec(p.spec)), + parsed, + ]; + const install = await installSpacePackages({ + pkgRoot: cfg.dir, + spaceKey: spaceKeyFor(spaceId, ownerId), + specs, + cfg, + bwrapAvailable, + }); + if (!install.ok) return { installError: install.error ?? 'install failed' }; + const next: PersistedState = { + packages: [ + ...state.packages, + { name: parsed.normalizedName, spec: parsed.spec, addedAt: new Date().toISOString() }, + ], + lockHash: install.lockHash, + updatedAt: new Date().toISOString(), + }; + repo.setSpacePythonPackages(spaceId, JSON.stringify(next)); + return { ok: true as const, state: next }; + }); +} diff --git a/src/bridge/ssh-admin-api.ts b/src/bridge/ssh-admin-api.ts new file mode 100644 index 0000000..68a6dd6 --- /dev/null +++ b/src/bridge/ssh-admin-api.ts @@ -0,0 +1,688 @@ +/** + * SSH HTTP layer — admin router (`/api/ssh/admin/*`). + * + * Extracted verbatim from src/bridge/ssh-api.ts (split refactor). Shared + * types / helpers live in ssh-api-shared.ts; the user router lives in + * ssh-user-api.ts. Mounted from src/bridge/server.ts (via ssh-subsystem) + * ONLY when `ssh.enabled=true` AND `MCP_ENCRYPTION_KEY` is configured. + * Every admin write path requires `body.reason` >= 8 chars and is audit + * logged. See the design conventions in the ssh-api.ts barrel header. + */ + +import { Router } from 'express'; + +import type { SshConnectionRepo } from '../ssh/connection-repo.js'; +import type { SshGrant, SshGrantSubjectType } from '../ssh/grants-repo.js'; +import { logger } from '../logger.js'; +import { + type SshApiDeps, + type SshEncryptResult, + validateReason, + maintenance503, + safePort, + safeString, + safeOptionalString, + safePathPrefix, + safeUuid, + safeExpiresAt, + safeSubjectType, + presentConnection, + presentGrant, + safeLimit, + jsonError, + makeSshLocalUserMiddleware, +} from './ssh-api-shared.js'; +// ────────────────────────────────────────────────────────────────────── +// Admin router — /api/ssh/admin/* +// ────────────────────────────────────────────────────────────────────── + +export function createSshAdminRouter(deps: SshApiDeps): Router { + const router = Router(); + router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); + + // GET /api/ssh/admin/connections + router.get('/connections', deps.requireAdmin, (_req, res) => { + try { + const list = deps.connectionRepo.listAll(); + res.json({ connections: list.map(presentConnection) }); + } catch (e) { + logger.warn(`[ssh:api] admin list connections failed err=${String(e)}`); + jsonError(res, 500, 'list_failed'); + } + }); + + router.get('/connections/:id', deps.requireAdmin, (req, res) => { + try { + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + let publicKey: string | null = null; + try { + publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId); + } catch (e) { + logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`); + } + res.json({ connection: presentConnection(conn), publicKey }); + } catch (e) { + logger.warn(`[ssh:api] admin get connection failed err=${String(e)}`); + jsonError(res, 500, 'get_failed'); + } + }); + + router.patch('/connections/:id/disable', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + const ok = deps.connectionRepo.disableByAdmin(conn.id, String(req.body.reason), userId); + if (!ok) { jsonError(res, 404, 'not_found'); return; } + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.disable', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(req.body.reason), + }, + 'success', + ); + const updated = deps.connectionRepo.resolveConnection(conn.id)!; + res.json({ connection: presentConnection(updated) }); + } catch (e) { + logger.warn(`[ssh:api] admin disable failed err=${String(e)}`); + jsonError(res, 500, 'disable_failed'); + } + }); + + router.patch('/connections/:id/enable', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + const ok = deps.connectionRepo.enableByAdmin(conn.id); + if (!ok) { jsonError(res, 404, 'not_found'); return; } + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.enable', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(req.body.reason), + }, + 'success', + ); + const updated = deps.connectionRepo.resolveConnection(conn.id)!; + res.json({ connection: presentConnection(updated) }); + } catch (e) { + logger.warn(`[ssh:api] admin enable failed err=${String(e)}`); + jsonError(res, 500, 'enable_failed'); + } + }); + + router.delete('/connections/:id', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + const auditId = deps.auditRepo.begin({ + action: 'ssh.connection.delete', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(req.body.reason), + }); + const ok = deps.connectionRepo.delete(conn.id); + if (!ok) { + deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); + jsonError(res, 404, 'not_found'); + return; + } + deps.auditRepo.complete(auditId, 'success'); + res.json({ ok: true }); + } catch (e) { + logger.warn(`[ssh:api] admin delete failed err=${String(e)}`); + jsonError(res, 500, 'delete_failed'); + } + }); + + router.post('/connections/:id/force-unlock', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const decision = deps.forceUnlockLimiter.check(userId); + if (!decision.allowed) { + if (decision.retryAfterSeconds !== undefined) { + res.setHeader('Retry-After', String(decision.retryAfterSeconds)); + } + jsonError(res, 429, 'rate_limited', { retryAfterSeconds: decision.retryAfterSeconds }); + return; + } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + const removed = deps.abuseRepo.reset(`conn:${conn.id}`); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.abuse.unlock_manual', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(req.body.reason), + detail: { removed }, + }, + 'success', + ); + res.json({ ok: true, removed }); + } catch (e) { + logger.warn(`[ssh:api] admin force-unlock failed err=${String(e)}`); + jsonError(res, 500, 'unlock_failed'); + } + }); + + // POST /api/ssh/admin/globals — create a global connection (owner_id=NULL). + router.post('/globals', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const body = req.body ?? {}; + const label = safeString(body.label, 200); + const host = safeString(body.host, 255); + const port = safePort(body.port); + const username = safeString(body.username, 64); + const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); + const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); + const allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; + const allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; + + // remote_path_prefix is required UNLESS allow_remote_unrestricted is set. + let remotePathPrefix: string | null; + if (allowRemoteUnrestricted) { + // Stored prefix is '/' (sandbox-effectively-disabled). + remotePathPrefix = '/'; + } else { + remotePathPrefix = safePathPrefix(body.remotePathPrefix); + } + + // Keypair source: 'provided' (admin uploads PEM) or 'generate' (orchestrator + // creates a fresh keypair; the public key is returned exactly once). + const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; + let pemBuf: Buffer; + let passBuf: Buffer | null; + if (keypairSource === 'generate') { + const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; + const generated = deps.generateKeypair(keyType); + pemBuf = generated.privateKeyPem; + passBuf = null; + } else { + const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; + const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; + if (!privateKey) { + jsonError(res, 400, 'invalid_input', { + required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], + }); + return; + } + pemBuf = Buffer.from(privateKey, 'utf8'); + passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; + } + + if (!label || !host || port === null || !username || !remotePathPrefix) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_input', { + required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], + }); + return; + } + if (denyPatterns === null) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_command_deny_patterns'); return; + } + if (allowPatterns === null) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_command_allow_patterns'); return; + } + + let encrypted: SshEncryptResult; + try { + encrypted = deps.encryptKeyMaterial(null, pemBuf, passBuf); + } catch (e) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + logger.warn(`[ssh:api] admin global encrypt failed err=${String(e)}`); + jsonError(res, 500, 'encrypt_failed'); + return; + } + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + + try { + const conn = deps.connectionRepo.create({ + ownerId: null, + label, host, port, username, + privateKeyEnc: encrypted.blob, + passphraseEnc: encrypted.passphraseBlob, + keyVersion: encrypted.keyVersion, + keyFingerprint: encrypted.fingerprint, + remotePathPrefix, + allowRemoteUnrestricted, + allowPrivateAddresses, + commandDenyPatterns: denyPatterns ?? null, + commandAllowPatterns: allowPatterns ?? null, + }); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.upsert', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: null, + actingUserId: userId, + reason: String(body.reason), + detail: { op: 'create', scope: 'global', allowRemoteUnrestricted, allowPrivateAddresses, keypairSource }, + }, + 'success', + ); + res.status(201).json({ + connection: presentConnection(conn), + publicKey: encrypted.publicKey, + }); + } catch (e) { + logger.warn(`[ssh:api] admin global create failed err=${String(e)}`); + jsonError(res, 500, 'create_failed'); + } + }); + + router.patch('/globals/:id', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } + const body = req.body ?? {}; + const patch: Parameters[1] = {}; + if (body.label !== undefined) { + const v = safeString(body.label, 200); + if (v === null) { jsonError(res, 400, 'invalid_label'); return; } + patch.label = v; + } + if (body.host !== undefined) { + const v = safeString(body.host, 255); + if (v === null) { jsonError(res, 400, 'invalid_host'); return; } + patch.host = v; + } + if (body.port !== undefined) { + const v = safePort(body.port); + if (v === null) { jsonError(res, 400, 'invalid_port'); return; } + patch.port = v; + } + if (body.username !== undefined) { + const v = safeString(body.username, 64); + if (v === null) { jsonError(res, 400, 'invalid_username'); return; } + patch.username = v; + } + if (body.remotePathPrefix !== undefined) { + const v = safePathPrefix(body.remotePathPrefix); + if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } + patch.remotePathPrefix = v; + } + if (body.commandDenyPatterns !== undefined) { + const v = safeOptionalString(body.commandDenyPatterns, 4096); + if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } + patch.commandDenyPatterns = v ?? null; + } + if (body.commandAllowPatterns !== undefined) { + const v = safeOptionalString(body.commandAllowPatterns, 4096); + if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } + patch.commandAllowPatterns = v ?? null; + } + if (body.allowRemoteUnrestricted !== undefined) { + patch.allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; + } + if (body.allowPrivateAddresses !== undefined) { + patch.allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; + } + if (typeof body.privateKeyPem === 'string') { + let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); + let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; + try { + const enc = deps.encryptKeyMaterial(null, pemBuf, passBuf); + patch.privateKeyEnc = enc.blob; + patch.passphraseEnc = enc.passphraseBlob; + patch.keyVersion = enc.keyVersion; + patch.keyFingerprint = enc.fingerprint; + } finally { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + } + } + const ok = deps.connectionRepo.update(conn.id, patch); + if (!ok) { jsonError(res, 404, 'not_found'); return; } + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.upsert', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: null, + actingUserId: userId, + reason: String(req.body.reason), + detail: { op: 'update', scope: 'global', fields: Object.keys(patch) }, + }, + 'success', + ); + const updated = deps.connectionRepo.resolveConnection(conn.id)!; + res.json({ connection: presentConnection(updated) }); + } catch (e) { + logger.warn(`[ssh:api] admin global patch failed err=${String(e)}`); + jsonError(res, 500, 'update_failed'); + } + }); + + router.delete('/globals/:id', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } + const auditId = deps.auditRepo.begin({ + action: 'ssh.connection.delete', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: null, + actingUserId: userId, + reason: String(req.body.reason), + }); + const ok = deps.connectionRepo.delete(conn.id); + if (!ok) { + deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); + jsonError(res, 404, 'not_found'); + return; + } + deps.auditRepo.complete(auditId, 'success'); + res.json({ ok: true }); + } catch (e) { + logger.warn(`[ssh:api] admin global delete failed err=${String(e)}`); + jsonError(res, 500, 'delete_failed'); + } + }); + + router.get('/grants', deps.requireAdmin, (req, res) => { + try { + const limit = safeLimit(req.query.limit, 100, 1000); + const rows = deps.db.prepare( + `SELECT * FROM ssh_connection_grants ORDER BY created_at DESC LIMIT ?`, + ).all(limit) as Array<{ + id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; + piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; + reason: string; expires_at: string | null; created_at: string; + }>; + const grants = rows.map((r) => ({ + id: r.id, + connectionId: r.connection_id, + subjectType: r.subject_type, + subjectId: r.subject_id, + pieceName: r.piece_name, + appliesToAllPieces: r.applies_to_all_pieces === 1, + grantedByUserId: r.granted_by_user_id, + reason: r.reason, + expiresAt: r.expires_at, + createdAt: r.created_at, + })); + res.json({ grants }); + } catch (e) { + logger.warn(`[ssh:api] admin list grants failed err=${String(e)}`); + jsonError(res, 500, 'list_grants_failed'); + } + }); + + router.post('/grants', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const body = req.body ?? {}; + const reasonErr = validateReason(body.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const connectionId = safeUuid(body.connectionId); + const subjectType = safeSubjectType(body.subjectType); + const subjectId = safeString(body.subjectId, 128); + const appliesToAllPieces = body.appliesToAllPieces === true || body.appliesToAllPieces === 1; + const pieceName = safeOptionalString(body.pieceName, 64); + const expiresAt = safeExpiresAt(body.expiresAt); + if (!connectionId || !subjectType || !subjectId) { + jsonError(res, 400, 'invalid_input', { + required: ['connectionId', 'subjectType ("user"|"org")', 'subjectId', 'reason'], + }); + return; + } + if (pieceName === null) { jsonError(res, 400, 'invalid_piece_name'); return; } + if (expiresAt === null) { jsonError(res, 400, 'invalid_expires_at'); return; } + if (!appliesToAllPieces && (!pieceName || pieceName.length === 0)) { + jsonError(res, 400, 'piece_name_required_unless_applies_to_all'); return; + } + if (appliesToAllPieces && pieceName !== undefined && pieceName.length > 0) { + jsonError(res, 400, 'piece_name_conflicts_with_applies_to_all'); return; + } + // Connection must exist before granting. + const conn = deps.connectionRepo.resolveConnection(connectionId); + if (!conn) { jsonError(res, 404, 'connection_not_found'); return; } + let grant: SshGrant; + try { + grant = deps.grantsRepo.create({ + connectionId, + subjectType, + subjectId, + pieceName: appliesToAllPieces ? null : (pieceName ?? null), + appliesToAllPieces, + grantedByUserId: userId, + reason: String(body.reason), + expiresAt, + }); + } catch (e) { + jsonError(res, 400, 'grant_create_failed', { detail: String(e) }); return; + } + deps.auditRepo.beginAndComplete( + { + action: 'ssh.grant.create', + entityType: 'ssh_grant', + entityId: grant.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(body.reason), + detail: { subjectType, subjectId, pieceName: grant.pieceName, appliesToAllPieces }, + }, + 'success', + ); + res.status(201).json({ grant: presentGrant(grant) }); + } catch (e) { + logger.warn(`[ssh:api] admin grant create failed err=${String(e)}`); + jsonError(res, 500, 'grant_create_failed'); + } + }); + + router.delete('/grants/:id', deps.requireAdmin, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const grant = deps.grantsRepo.getById(req.params.id); + if (!grant) { jsonError(res, 404, 'not_found'); return; } + const ok = deps.grantsRepo.delete(grant.id); + if (!ok) { jsonError(res, 404, 'not_found'); return; } + // Kick any active console-WS viewers whose access depended on this grant. + // user-subject grants map 1:1 to a single userId; org-subject grants are + // deferred (next attach will be denied; existing viewers stay until the + // session ends or the org-member's WS reconnects). + let kicked: number | void = 0; + if (grant.subjectType === 'user' && deps.onAccessRevoked) { + kicked = deps.onAccessRevoked({ connectionId: grant.connectionId, userId: grant.subjectId }); + } + deps.auditRepo.beginAndComplete( + { + action: 'ssh.grant.delete', + entityType: 'ssh_grant', + entityId: grant.id, + connectionId: grant.connectionId, + actingUserId: userId, + reason: String(req.body.reason), + detail: { + subjectType: grant.subjectType, + subjectId: grant.subjectId, + pieceName: grant.pieceName, + viewersKicked: typeof kicked === 'number' ? kicked : 0, + }, + }, + 'success', + ); + res.json({ ok: true }); + } catch (e) { + logger.warn(`[ssh:api] admin grant delete failed err=${String(e)}`); + jsonError(res, 500, 'grant_delete_failed'); + } + }); + + // POST /api/ssh/admin/rotate-master-key — stub for v1 (Phase 5). + // Sets the maintenance flag and audits the intent. The actual DEK re-wrap + // job is deferred to a follow-up PR per Phase 5 design note (line 728). + // Returns 501 in the response body but 202 status so the UI can show a + // banner; UI Phase 6 will key off the maintenance snapshot. + router.post('/rotate-master-key', deps.requireAdmin, (req, res) => { + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const reasonErr = validateReason(req.body?.reason); + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + if (deps.maintenance.isActive()) { + jsonError(res, 409, 'already_in_progress', { snapshot: deps.maintenance.snapshot() }); + return; + } + const jobId = `rotate-${Date.now().toString(36)}`; + deps.maintenance.enter(String(req.body.reason), jobId); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.master_key.rotate.start', + actingUserId: userId, + reason: String(req.body.reason), + detail: { jobId, status: 'stub', note: 'rotation job not implemented in v1; only maintenance flag set' }, + }, + 'success', + ); + res.status(202).json({ + jobId, + status: 'maintenance_set', + detail: 'maintenance flag is now active; actual DEK re-wrap is not implemented in v1', + notImplemented: true, + }); + } catch (e) { + logger.warn(`[ssh:api] admin rotate-master-key failed err=${String(e)}`); + jsonError(res, 500, 'rotate_failed'); + } + }); + + router.get('/rotate-master-key/:jobId', deps.requireAdmin, (req, res) => { + try { + const snap = deps.maintenance.snapshot(); + if (!snap.active || snap.jobId !== req.params.jobId) { + jsonError(res, 404, 'not_found_or_completed', { snapshot: snap }); + return; + } + res.json({ + jobId: snap.jobId, + status: 'in_progress', + startedAt: snap.enteredAt, + progress: { note: 'v1 stub: no rewrap performed' }, + notImplemented: true, + }); + } catch (e) { + logger.warn(`[ssh:api] admin rotate-master-key get failed err=${String(e)}`); + jsonError(res, 500, 'rotate_get_failed'); + } + }); + + // GET /api/ssh/admin/audit — cross-user audit query. + router.get('/audit', deps.requireAdmin, (req, res) => { + try { + const limit = safeLimit(req.query.limit, 100, 1000); + const action = typeof req.query.action === 'string' ? req.query.action : null; + const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : null; + const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : null; + const outcome = typeof req.query.outcome === 'string' ? req.query.outcome : null; + + const where: string[] = []; + const params: unknown[] = []; + if (action) { where.push('action = ?'); params.push(action); } + if (ownerId) { where.push('owner_id = ?'); params.push(ownerId); } + if (connectionId) { where.push('connection_id = ?'); params.push(connectionId); } + if (outcome) { where.push('outcome = ?'); params.push(outcome); } + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + const sql = `SELECT * FROM ssh_audit_log ${whereClause} ORDER BY started_at DESC LIMIT ?`; + params.push(limit); + const rows = deps.db.prepare(sql).all(...params) as Array<{ + id: number; action: string; entity_type: string | null; entity_id: string | null; + connection_id: string | null; owner_id: string | null; acting_user_id: string | null; + job_id: string | null; piece_name: string | null; outcome: string; + reason: string | null; detail: string | null; started_at: string; completed_at: string | null; + }>; + const audit = rows.map((r) => ({ + id: r.id, + action: r.action, + entityType: r.entity_type, + entityId: r.entity_id, + connectionId: r.connection_id, + ownerId: r.owner_id, + actingUserId: r.acting_user_id, + jobId: r.job_id, + pieceName: r.piece_name, + outcome: r.outcome, + reason: r.reason, + detail: r.detail ? JSON.parse(r.detail) : null, + startedAt: r.started_at, + completedAt: r.completed_at, + })); + res.json({ audit }); + } catch (e) { + logger.warn(`[ssh:api] admin audit list failed err=${String(e)}`); + jsonError(res, 500, 'audit_failed'); + } + }); + + return router; +} diff --git a/src/bridge/ssh-api-shared.ts b/src/bridge/ssh-api-shared.ts new file mode 100644 index 0000000..55e6957 --- /dev/null +++ b/src/bridge/ssh-api-shared.ts @@ -0,0 +1,318 @@ +/** + * SSH HTTP layer — shared types & helpers. + * + * Extracted verbatim from src/bridge/ssh-api.ts (split refactor). Holds the + * dependency-injection surface (`SshApiDeps`), the tester contracts, and the + * validation / presentation helpers used by both routers: + * ssh-user-api.ts — createSshUserRouter + * ssh-admin-api.ts — createSshAdminRouter + * The original module remains as a re-export barrel, so external importers + * are unaffected. + */ + +import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express'; +import type Database from 'better-sqlite3'; + +import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js'; +import type { SshGrant, SshGrantsRepo, SshGrantSubjectType } from '../ssh/grants-repo.js'; +import type { SshAuditRepo, SshAuditRow } from '../ssh/audit-repo.js'; +import type { SshAbuseRepo } from '../ssh/abuse-repo.js'; +import type { SshAccessResolver } from '../ssh/access.js'; +import type { MaintenanceController } from '../ssh/maintenance.js'; +import type { AdminRateLimiter } from '../ssh/admin-rate-limit.js'; +import { logger } from '../logger.js'; + +// ────────────────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────────────────── + +export interface SshTesterArgs { + connection: SshConnection; + decryptedKey: Buffer; + passphrase: Buffer | null; + timeoutMs: number; +} + +export type SshTesterVerdict = 'first_observe' | 'mismatch' | 'pass' | 'alg_not_allowed'; + +export interface SshTesterResult { + /** SHA256 fingerprint of the observed host key. */ + fingerprint: string; + /** Base64 of the observed host key (wire format). */ + hostKeyB64: string; + hostKeyType: string; + verdict: SshTesterVerdict; +} + +export interface SshTester { + test(args: SshTesterArgs): Promise; +} + +export interface SshEncryptResult { + blob: Buffer; + passphraseBlob: Buffer | null; + keyVersion: number; + fingerprint: string; + publicKey: string; +} + +export interface SshApiDeps { + db: Database.Database; + requireAuth: RequestHandler; + requireAdmin: RequestHandler; + getUserId(req: Request): string | null; + isAdmin(req: Request): boolean; + getOrgIds(req: Request): string[]; + /** + * Spaces foundation (spec §11). Authorizes a `space_id` supplied on create / + * list against the requesting viewer and resolves the space's existence. + * When omitted, space-scoped requests are rejected (no way to authorize + * them) while legacy per-user create/list keeps working. Mirrors mcp-api. + */ + getSpace?: (spaceId: string, viewer?: Express.User) => Promise<{ id: string } | null>; + /** + * Whether the bridge wired the auth subsystem. When `false` (no-auth + * single-user deployment) requests carry no `req.user`, so the router + * synthesizes a stable `local` owner — otherwise every endpoint 401s on + * the null userId and SSH is unusable. Mirrors memory-api / + * user-folder-api. Defaults to `true` for existing (auth-only) callers. + */ + authActive?: boolean; + + connectionRepo: SshConnectionRepo; + grantsRepo: SshGrantsRepo; + auditRepo: SshAuditRepo; + abuseRepo: SshAbuseRepo; + accessResolver: SshAccessResolver; + + maintenance: MaintenanceController; + forceUnlockLimiter: AdminRateLimiter; + + /** + * Encrypt a PEM (and optional passphrase) into the envelope-encrypted blobs + * stored in ssh_connections. ownerId=null = system DEK (global connection), + * ownerId= = user DEK. + */ + encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null, spaceId?: string | null): SshEncryptResult; + + /** + * Decrypt blob using the correct DEK. Buffer must be cleared by caller. + * spaceId (Space-as-Principal P2): when given, the space DEK is tried first + * with a fallback to the owner/system DEK (lets pre-migration owner-encrypted + * blobs of a now-space-owned connection still decrypt). + */ + decryptKeyMaterial(ownerId: string | null, blob: Buffer, spaceId?: string | null): Buffer; + decryptPassphrase(ownerId: string | null, blob: Buffer | null, spaceId?: string | null): Buffer | null; + + /** + * Generate a fresh keypair (no passphrase). The caller is expected to feed + * the returned `privateKeyPem` back through `encryptKeyMaterial` for at-rest + * encryption. + */ + generateKeypair(keyType: 'ed25519' | 'rsa-4096'): { + privateKeyPem: Buffer; + publicKey: string; + }; + + /** + * Derive the OpenSSH-format public key (` `) from the stored + * private-key blob. Handles decrypt + format + buffer-clear internally. + */ + derivePublicKey( + ownerId: string | null, + blob: Buffer, + passphraseBlob: Buffer | null, + spaceId?: string | null, + ): string; + + /** SSH dial helper — injected so tests don't need a real SSH server. */ + sshTester: SshTester; + + /** Connection test timeout. Defaults to 5s. */ + connectionTestTimeoutMs?: number; + + /** + * Optional hook called after a grant is revoked / deleted so the caller can + * kick any active WebSocket viewers that depended on that grant. The hook + * is a no-op pass-through (returns 0) when the SSH console subsystem isn't + * wired (e.g. tests, or `ssh.console.enabled=false`). + * + * Implementation typically calls `SessionRegistry.revokeAccessFor`. + */ + onAccessRevoked?: (args: { connectionId: string; userId: string }) => number | void; +} + +// ────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────── + +export const REASON_MIN_CHARS = 8; + +export function validateReason(reason: unknown): string | null { + if (typeof reason !== 'string') return 'reason is required (string >= 8 chars)'; + if (reason.length < REASON_MIN_CHARS) return `reason must be at least ${REASON_MIN_CHARS} characters`; + return null; +} + +export function maintenance503(maintenance: MaintenanceController, res: Response): boolean { + if (!maintenance.isActive()) return false; + res.setHeader('Retry-After', '30'); + res.status(503).json({ + error: 'rotation_in_progress', + detail: 'SSH subsystem is in maintenance — try again shortly.', + snapshot: maintenance.snapshot(), + }); + return true; +} + +export function safePort(p: unknown): number | null { + if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) return null; + return p; +} + +export function safeString(v: unknown, max = 1024): string | null { + if (typeof v !== 'string') return null; + if (v.length === 0 || v.length > max) return null; + return v; +} + +export function safeOptionalString(v: unknown, max = 1024): string | null | undefined { + if (v === undefined || v === null) return undefined; + if (typeof v !== 'string') return null; + if (v.length > max) return null; + return v; +} + +export function safePathPrefix(v: unknown): string | null { + const s = safeString(v, 1024); + if (s === null) return null; + if (s.length === 0) return null; + // Reject any `..` parent-ref segment using either separator. POSIX and + // Windows-style prefixes (drive letters, UNC, no-leading-slash) are + // all allowed; the runtime validateRemotePath enforces containment. + const segments = s.split(/[\\/]/); + if (segments.includes('..')) return null; + return s; +} + +export function safeFingerprint(v: unknown): string | null { + if (typeof v !== 'string') return null; + // SHA256: — base64 chars + ='s, ~46 chars typical. + if (!/^SHA256:[A-Za-z0-9+/=]{20,80}$/.test(v)) return null; + return v; +} + +export function safeUuid(v: unknown): string | null { + if (typeof v !== 'string') return null; + if (!/^[a-f0-9-]{8,}$/.test(v)) return null; + return v; +} + +export function safeExpiresAt(v: unknown): string | null | undefined { + if (v === undefined || v === null) return undefined; + if (typeof v !== 'string') return null; + // ISO8601 — Date.parse returns NaN for invalid strings. + const t = Date.parse(v); + if (Number.isNaN(t)) return null; + return new Date(t).toISOString(); +} + +export function safeSubjectType(v: unknown): SshGrantSubjectType | null { + return v === 'user' || v === 'org' ? v : null; +} + +/** + * Normalize a space id from a request body or query (spec §11). Accepts both + * `space_id` (snake, wire convention) and `spaceId` (camel). Returns: string + * (a space), null (explicit global / space-less), or undefined (not given). + * Mirrors mcp-api.readSpaceId. + */ +export function readSpaceId(src: Record | undefined): string | null | undefined { + if (!src) return undefined; + const raw = (src.space_id ?? src.spaceId) as unknown; + if (raw === undefined) return undefined; + if (raw === null || raw === '') return null; + // 文字列のみ受理(数値等の coerce を避ける)。 + return typeof raw === 'string' ? raw : undefined; +} + +export function presentConnection(conn: SshConnection): Record { + // Strip the encrypted blob fields and the pending verify token (the token + // is only surfaced on /test responses; subsequent GETs do not expose it + // again — the user must hold onto it). + return { + id: conn.id, + ownerId: conn.ownerId, + label: conn.label, + host: conn.host, + port: conn.port, + username: conn.username, + keyVersion: conn.keyVersion, + keyFingerprint: conn.keyFingerprint, + hostKeyType: conn.hostKeyType, + hostKeyFingerprint: conn.hostKeyFingerprint, + hostKeyRecordedAt: conn.hostKeyRecordedAt, + hostKeyVerifiedAt: conn.hostKeyVerifiedAt, + hostKeyPending: conn.hostKeyPending, + hostKeyPendingFingerprint: conn.hostKeyPendingFingerprint, + hostKeyPendingSource: conn.hostKeyPendingSource, + commandDenyPatterns: conn.commandDenyPatterns, + commandAllowPatterns: conn.commandAllowPatterns, + remotePathPrefix: conn.remotePathPrefix, + allowRemoteUnrestricted: conn.allowRemoteUnrestricted, + allowPrivateAddresses: conn.allowPrivateAddresses, + enabled: conn.enabled, + disabledByAdmin: conn.disabledByAdmin, + disabledByAdminReason: conn.disabledByAdminReason, + disabledByAdminAt: conn.disabledByAdminAt, + disabledByAdminUserId: conn.disabledByAdminUserId, + createdAt: conn.createdAt, + updatedAt: conn.updatedAt, + }; +} + +export function presentGrant(g: SshGrant): Record { + return { ...g }; +} + +export function presentAuditRow(a: SshAuditRow): Record { + return { ...a }; +} + +export function safeLimit(v: unknown, def = 50, max = 500): number { + if (typeof v === 'string') { + const n = Number(v); + if (Number.isInteger(n) && n > 0 && n <= max) return n; + } + if (typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= max) return v; + return def; +} + +export function jsonError(res: Response, status: number, error: string, detail?: unknown): void { + if (detail === undefined) { + res.status(status).json({ error }); + } else { + res.status(status).json({ error, detail }); + } +} + +// ────────────────────────────────────────────────────────────────────── +// User router — /api/ssh/connections + /api/ssh/grants +// ────────────────────────────────────────────────────────────────────── + +/** + * No-auth single-user mode: synthesize a `local` user so the per-user + * connection store has a stable owner and `getUserId`/`isAdmin` resolve. + * Role `admin` keeps the connection-management surface fully usable for the + * lone local operator (in single-user mode the admin/grant checks only ever + * compare against `local`-owned rows, so this never widens cross-user access). + * No-op when auth is active (Passport has already populated `req.user`). + */ +export function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler { + return (req: Request, _res: Response, next: NextFunction) => { + if (!authActive && !(req as { user?: unknown }).user) { + (req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] }; + } + next(); + }; +} diff --git a/src/bridge/ssh-api.ts b/src/bridge/ssh-api.ts index 3b1d6f2..d0ab435 100644 --- a/src/bridge/ssh-api.ts +++ b/src/bridge/ssh-api.ts @@ -23,1522 +23,20 @@ * cleared on every code path (success, error, validation failure). * - Response shaping: `presentConnection()` strips encrypted blob fields and * the pending host-key token (token only returned on test / observation). + * + * This module is a re-export barrel (split refactor): the implementation + * lives in ssh-api-shared.ts (types & helpers), ssh-user-api.ts + * (createSshUserRouter) and ssh-admin-api.ts (createSshAdminRouter). + * Existing importers keep working unchanged. */ -import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express'; -import type Database from 'better-sqlite3'; - -import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js'; -import type { SshGrant, SshGrantsRepo, SshGrantSubjectType } from '../ssh/grants-repo.js'; -import type { SshAuditRepo, SshAuditRow } from '../ssh/audit-repo.js'; -import type { SshAbuseRepo } from '../ssh/abuse-repo.js'; -import type { SshAccessResolver } from '../ssh/access.js'; -import type { MaintenanceController } from '../ssh/maintenance.js'; -import type { AdminRateLimiter } from '../ssh/admin-rate-limit.js'; -import { logger } from '../logger.js'; - -// ────────────────────────────────────────────────────────────────────── -// Types -// ────────────────────────────────────────────────────────────────────── - -export interface SshTesterArgs { - connection: SshConnection; - decryptedKey: Buffer; - passphrase: Buffer | null; - timeoutMs: number; -} - -export type SshTesterVerdict = 'first_observe' | 'mismatch' | 'pass' | 'alg_not_allowed'; - -export interface SshTesterResult { - /** SHA256 fingerprint of the observed host key. */ - fingerprint: string; - /** Base64 of the observed host key (wire format). */ - hostKeyB64: string; - hostKeyType: string; - verdict: SshTesterVerdict; -} - -export interface SshTester { - test(args: SshTesterArgs): Promise; -} - -export interface SshEncryptResult { - blob: Buffer; - passphraseBlob: Buffer | null; - keyVersion: number; - fingerprint: string; - publicKey: string; -} - -export interface SshApiDeps { - db: Database.Database; - requireAuth: RequestHandler; - requireAdmin: RequestHandler; - getUserId(req: Request): string | null; - isAdmin(req: Request): boolean; - getOrgIds(req: Request): string[]; - /** - * Spaces foundation (spec §11). Authorizes a `space_id` supplied on create / - * list against the requesting viewer and resolves the space's existence. - * When omitted, space-scoped requests are rejected (no way to authorize - * them) while legacy per-user create/list keeps working. Mirrors mcp-api. - */ - getSpace?: (spaceId: string, viewer?: Express.User) => Promise<{ id: string } | null>; - /** - * Whether the bridge wired the auth subsystem. When `false` (no-auth - * single-user deployment) requests carry no `req.user`, so the router - * synthesizes a stable `local` owner — otherwise every endpoint 401s on - * the null userId and SSH is unusable. Mirrors memory-api / - * user-folder-api. Defaults to `true` for existing (auth-only) callers. - */ - authActive?: boolean; - - connectionRepo: SshConnectionRepo; - grantsRepo: SshGrantsRepo; - auditRepo: SshAuditRepo; - abuseRepo: SshAbuseRepo; - accessResolver: SshAccessResolver; - - maintenance: MaintenanceController; - forceUnlockLimiter: AdminRateLimiter; - - /** - * Encrypt a PEM (and optional passphrase) into the envelope-encrypted blobs - * stored in ssh_connections. ownerId=null = system DEK (global connection), - * ownerId= = user DEK. - */ - encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null, spaceId?: string | null): SshEncryptResult; - - /** - * Decrypt blob using the correct DEK. Buffer must be cleared by caller. - * spaceId (Space-as-Principal P2): when given, the space DEK is tried first - * with a fallback to the owner/system DEK (lets pre-migration owner-encrypted - * blobs of a now-space-owned connection still decrypt). - */ - decryptKeyMaterial(ownerId: string | null, blob: Buffer, spaceId?: string | null): Buffer; - decryptPassphrase(ownerId: string | null, blob: Buffer | null, spaceId?: string | null): Buffer | null; - - /** - * Generate a fresh keypair (no passphrase). The caller is expected to feed - * the returned `privateKeyPem` back through `encryptKeyMaterial` for at-rest - * encryption. - */ - generateKeypair(keyType: 'ed25519' | 'rsa-4096'): { - privateKeyPem: Buffer; - publicKey: string; - }; - - /** - * Derive the OpenSSH-format public key (` `) from the stored - * private-key blob. Handles decrypt + format + buffer-clear internally. - */ - derivePublicKey( - ownerId: string | null, - blob: Buffer, - passphraseBlob: Buffer | null, - spaceId?: string | null, - ): string; - - /** SSH dial helper — injected so tests don't need a real SSH server. */ - sshTester: SshTester; - - /** Connection test timeout. Defaults to 5s. */ - connectionTestTimeoutMs?: number; - - /** - * Optional hook called after a grant is revoked / deleted so the caller can - * kick any active WebSocket viewers that depended on that grant. The hook - * is a no-op pass-through (returns 0) when the SSH console subsystem isn't - * wired (e.g. tests, or `ssh.console.enabled=false`). - * - * Implementation typically calls `SessionRegistry.revokeAccessFor`. - */ - onAccessRevoked?: (args: { connectionId: string; userId: string }) => number | void; -} - -// ────────────────────────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────────────────────────── - -const REASON_MIN_CHARS = 8; - -function validateReason(reason: unknown): string | null { - if (typeof reason !== 'string') return 'reason is required (string >= 8 chars)'; - if (reason.length < REASON_MIN_CHARS) return `reason must be at least ${REASON_MIN_CHARS} characters`; - return null; -} - -function maintenance503(maintenance: MaintenanceController, res: Response): boolean { - if (!maintenance.isActive()) return false; - res.setHeader('Retry-After', '30'); - res.status(503).json({ - error: 'rotation_in_progress', - detail: 'SSH subsystem is in maintenance — try again shortly.', - snapshot: maintenance.snapshot(), - }); - return true; -} - -function safePort(p: unknown): number | null { - if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) return null; - return p; -} - -function safeString(v: unknown, max = 1024): string | null { - if (typeof v !== 'string') return null; - if (v.length === 0 || v.length > max) return null; - return v; -} - -function safeOptionalString(v: unknown, max = 1024): string | null | undefined { - if (v === undefined || v === null) return undefined; - if (typeof v !== 'string') return null; - if (v.length > max) return null; - return v; -} - -function safePathPrefix(v: unknown): string | null { - const s = safeString(v, 1024); - if (s === null) return null; - if (s.length === 0) return null; - // Reject any `..` parent-ref segment using either separator. POSIX and - // Windows-style prefixes (drive letters, UNC, no-leading-slash) are - // all allowed; the runtime validateRemotePath enforces containment. - const segments = s.split(/[\\/]/); - if (segments.includes('..')) return null; - return s; -} - -function safeFingerprint(v: unknown): string | null { - if (typeof v !== 'string') return null; - // SHA256: — base64 chars + ='s, ~46 chars typical. - if (!/^SHA256:[A-Za-z0-9+/=]{20,80}$/.test(v)) return null; - return v; -} - -function safeUuid(v: unknown): string | null { - if (typeof v !== 'string') return null; - if (!/^[a-f0-9-]{8,}$/.test(v)) return null; - return v; -} - -function safeExpiresAt(v: unknown): string | null | undefined { - if (v === undefined || v === null) return undefined; - if (typeof v !== 'string') return null; - // ISO8601 — Date.parse returns NaN for invalid strings. - const t = Date.parse(v); - if (Number.isNaN(t)) return null; - return new Date(t).toISOString(); -} - -function safeSubjectType(v: unknown): SshGrantSubjectType | null { - return v === 'user' || v === 'org' ? v : null; -} - -/** - * Normalize a space id from a request body or query (spec §11). Accepts both - * `space_id` (snake, wire convention) and `spaceId` (camel). Returns: string - * (a space), null (explicit global / space-less), or undefined (not given). - * Mirrors mcp-api.readSpaceId. - */ -function readSpaceId(src: Record | undefined): string | null | undefined { - if (!src) return undefined; - const raw = (src.space_id ?? src.spaceId) as unknown; - if (raw === undefined) return undefined; - if (raw === null || raw === '') return null; - // 文字列のみ受理(数値等の coerce を避ける)。 - return typeof raw === 'string' ? raw : undefined; -} - -function presentConnection(conn: SshConnection): Record { - // Strip the encrypted blob fields and the pending verify token (the token - // is only surfaced on /test responses; subsequent GETs do not expose it - // again — the user must hold onto it). - return { - id: conn.id, - ownerId: conn.ownerId, - label: conn.label, - host: conn.host, - port: conn.port, - username: conn.username, - keyVersion: conn.keyVersion, - keyFingerprint: conn.keyFingerprint, - hostKeyType: conn.hostKeyType, - hostKeyFingerprint: conn.hostKeyFingerprint, - hostKeyRecordedAt: conn.hostKeyRecordedAt, - hostKeyVerifiedAt: conn.hostKeyVerifiedAt, - hostKeyPending: conn.hostKeyPending, - hostKeyPendingFingerprint: conn.hostKeyPendingFingerprint, - hostKeyPendingSource: conn.hostKeyPendingSource, - commandDenyPatterns: conn.commandDenyPatterns, - commandAllowPatterns: conn.commandAllowPatterns, - remotePathPrefix: conn.remotePathPrefix, - allowRemoteUnrestricted: conn.allowRemoteUnrestricted, - allowPrivateAddresses: conn.allowPrivateAddresses, - enabled: conn.enabled, - disabledByAdmin: conn.disabledByAdmin, - disabledByAdminReason: conn.disabledByAdminReason, - disabledByAdminAt: conn.disabledByAdminAt, - disabledByAdminUserId: conn.disabledByAdminUserId, - createdAt: conn.createdAt, - updatedAt: conn.updatedAt, - }; -} - -function presentGrant(g: SshGrant): Record { - return { ...g }; -} - -function presentAuditRow(a: SshAuditRow): Record { - return { ...a }; -} - -function safeLimit(v: unknown, def = 50, max = 500): number { - if (typeof v === 'string') { - const n = Number(v); - if (Number.isInteger(n) && n > 0 && n <= max) return n; - } - if (typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= max) return v; - return def; -} - -function jsonError(res: Response, status: number, error: string, detail?: unknown): void { - if (detail === undefined) { - res.status(status).json({ error }); - } else { - res.status(status).json({ error, detail }); - } -} - -// ────────────────────────────────────────────────────────────────────── -// User router — /api/ssh/connections + /api/ssh/grants -// ────────────────────────────────────────────────────────────────────── - -/** - * No-auth single-user mode: synthesize a `local` user so the per-user - * connection store has a stable owner and `getUserId`/`isAdmin` resolve. - * Role `admin` keeps the connection-management surface fully usable for the - * lone local operator (in single-user mode the admin/grant checks only ever - * compare against `local`-owned rows, so this never widens cross-user access). - * No-op when auth is active (Passport has already populated `req.user`). - */ -function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler { - return (req: Request, _res: Response, next: NextFunction) => { - if (!authActive && !(req as { user?: unknown }).user) { - (req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] }; - } - next(); - }; -} - -export function createSshUserRouter(deps: SshApiDeps): Router { - const router = Router(); - const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000; - router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); - - // GET /api/ssh/connections — list owned (and globals visible via grant). - // For Phase 5 we return: - // - all owned (owner_id == userId) - // - all globals (owner_id IS NULL) — visibility is enforced at tool-call - // time via accessResolver, so listing globals here is informational. - // Admins see all via /api/ssh/admin/connections (separate endpoint). - router.get('/connections', deps.requireAuth, async (req, res) => { - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - - // Space-scoped listing (spec §11): ?spaceId= (or ?space_id=) returns - // ONLY that space's connections, visibility-checked against the viewer. - // Omitting it keeps the legacy per-user list (owned + globals). - const spaceId = readSpaceId(req.query as Record); - if (typeof spaceId === 'string') { - if (!deps.getSpace) { jsonError(res, 400, 'space_scoped_listing_unavailable'); return; } - const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user); - if (!space) { jsonError(res, 404, 'unknown_or_inaccessible_space'); return; } - const scoped = deps.connectionRepo.listBySpace(spaceId); - res.json({ connections: scoped.map(presentConnection) }); - return; - } - - const owned = deps.connectionRepo.listOwned(userId); - const all = deps.connectionRepo.listAll(); - const globals = all.filter((c) => c.ownerId === null); - // Deduplicate (owned should never overlap globals; safety net). - const seen = new Set(owned.map((c) => c.id)); - const list = [...owned]; - for (const g of globals) { - if (!seen.has(g.id)) { list.push(g); seen.add(g.id); } - } - res.json({ connections: list.map(presentConnection) }); - } catch (e) { - logger.warn(`[ssh:api] list user connections failed err=${String(e)}`); - jsonError(res, 500, 'list_failed'); - } - }); - - // POST /api/ssh/connections — create user-owned. - router.post('/connections', deps.requireAuth, async (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const body = req.body ?? {}; - const label = safeString(body.label, 200); - const host = safeString(body.host, 255); - const port = safePort(body.port); - const username = safeString(body.username, 64); - const remotePathPrefix = safePathPrefix(body.remotePathPrefix); - const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); - const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); - // User-owned create rejects admin-only flags up front. - if (body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1) { - jsonError(res, 403, 'allow_remote_unrestricted_admin_only'); return; - } - if (body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1) { - jsonError(res, 403, 'allow_private_addresses_admin_only'); return; - } - - // Keypair source: 'provided' (user uploads PEM, default) or - // 'generate' (orchestrator creates a fresh keypair). In the latter case - // the user gets the public key back exactly once in this response. - const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; - let pemBuf: Buffer; - let passBuf: Buffer | null; - if (keypairSource === 'generate') { - const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; - const generated = deps.generateKeypair(keyType); - pemBuf = generated.privateKeyPem; - passBuf = null; - } else { - const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; - const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; - if (!privateKey) { - jsonError(res, 400, 'invalid_input', { - required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], - }); - return; - } - pemBuf = Buffer.from(privateKey, 'utf8'); - passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; - } - - if (!label || !host || port === null || !username || !remotePathPrefix) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_input', { - required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], - }); - return; - } - if (denyPatterns === null) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_command_deny_patterns'); return; - } - if (allowPatterns === null) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_command_allow_patterns'); return; - } - - // Per-space scoping (spec §11). When a space is supplied, authorize it - // against the requesting viewer via getSpace BEFORE encrypting/storing. - // owner_id stays = userId (created_by); space_id gates who may USE the - // connection at runtime (space membership) AND — Space-as-Principal P2 — - // selects the SPACE DEK for at-rest encryption so the key survives member - // turnover. Omitting it = legacy space-less (owner-DEK) connection. - const spaceId = readSpaceId(body as Record); - if (typeof spaceId === 'string') { - if (!deps.getSpace) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'space_scoped_create_unavailable'); return; - } - const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user); - if (!space) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 404, 'unknown_or_inaccessible_space'); return; - } - } - - const createSpaceId = typeof spaceId === 'string' ? spaceId : null; - let encrypted: SshEncryptResult; - try { - encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf, createSpaceId); - } finally { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - } - const conn = deps.connectionRepo.create({ - ownerId: userId, - spaceId: typeof spaceId === 'string' ? spaceId : null, - label, host, port, username, - privateKeyEnc: encrypted.blob, - passphraseEnc: encrypted.passphraseBlob, - keyVersion: encrypted.keyVersion, - keyFingerprint: encrypted.fingerprint, - remotePathPrefix, - commandDenyPatterns: denyPatterns ?? null, - commandAllowPatterns: allowPatterns ?? null, - }); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.upsert', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: userId, - actingUserId: userId, - detail: { op: 'create', label: conn.label, host: conn.host, port: conn.port, keypairSource }, - }, - 'success', - ); - res.status(201).json({ - connection: presentConnection(conn), - publicKey: encrypted.publicKey, - }); - } catch (e) { - logger.warn(`[ssh:api] create user connection failed err=${String(e)}`); - jsonError(res, 500, 'create_failed'); - } - }); - - router.get('/connections/:id', deps.requireAuth, (req, res) => { - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - // Visibility: owner, admin, or global (info-only). Other users see 404 - // (do not leak existence of someone else's user-owned connection). - if (conn.ownerId !== null && conn.ownerId !== userId && !deps.isAdmin(req)) { - jsonError(res, 404, 'not_found'); return; - } - let publicKey: string | null = null; - try { - publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId); - } catch (e) { - // Don't fail the whole response if key derivation fails (e.g. corrupt - // blob, missing passphrase) — return the connection without it and log. - logger.warn(`[ssh:api] derive public key failed id=${conn.id} err=${String(e)}`); - } - res.json({ connection: presentConnection(conn), publicKey }); - } catch (e) { - logger.warn(`[ssh:api] get user connection failed err=${String(e)}`); - jsonError(res, 500, 'get_failed'); - } - }); - - router.patch('/connections/:id', deps.requireAuth, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - // Only owner may PATCH user-owned. Admin PATCHes globals via admin route. - if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } - const body = req.body ?? {}; - - const patch: Parameters[1] = {}; - if (body.label !== undefined) { - const v = safeString(body.label, 200); - if (v === null) { jsonError(res, 400, 'invalid_label'); return; } - patch.label = v; - } - if (body.host !== undefined) { - const v = safeString(body.host, 255); - if (v === null) { jsonError(res, 400, 'invalid_host'); return; } - patch.host = v; - } - if (body.port !== undefined) { - const v = safePort(body.port); - if (v === null) { jsonError(res, 400, 'invalid_port'); return; } - patch.port = v; - } - if (body.username !== undefined) { - const v = safeString(body.username, 64); - if (v === null) { jsonError(res, 400, 'invalid_username'); return; } - patch.username = v; - } - if (body.remotePathPrefix !== undefined) { - const v = safePathPrefix(body.remotePathPrefix); - if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } - patch.remotePathPrefix = v; - } - if (body.commandDenyPatterns !== undefined) { - const v = safeOptionalString(body.commandDenyPatterns, 4096); - if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } - patch.commandDenyPatterns = v ?? null; - } - if (body.commandAllowPatterns !== undefined) { - const v = safeOptionalString(body.commandAllowPatterns, 4096); - if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } - patch.commandAllowPatterns = v ?? null; - } - // Users cannot toggle admin-only flags. - if (body.allowRemoteUnrestricted !== undefined || body.allowPrivateAddresses !== undefined) { - jsonError(res, 403, 'admin_only_flag'); return; - } - // Key rotation (privateKeyPem in patch). Re-encrypt and bump key_version. - if (typeof body.privateKeyPem === 'string') { - let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); - let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; - try { - // Re-encrypt under the connection's current DEK domain (space DEK when - // space-owned) so a key rotation keeps the space-ownership property. - const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf, conn.spaceId); - patch.privateKeyEnc = enc.blob; - patch.passphraseEnc = enc.passphraseBlob; - patch.keyVersion = enc.keyVersion; - patch.keyFingerprint = enc.fingerprint; - } finally { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - } - } - const ok = deps.connectionRepo.update(conn.id, patch); - if (!ok) { jsonError(res, 404, 'not_found'); return; } - const updated = deps.connectionRepo.resolveConnection(conn.id); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.upsert', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: userId, - actingUserId: userId, - detail: { op: 'update', fields: Object.keys(patch) }, - }, - 'success', - ); - res.json({ connection: presentConnection(updated!) }); - } catch (e) { - logger.warn(`[ssh:api] patch user connection failed err=${String(e)}`); - jsonError(res, 500, 'update_failed'); - } - }); - - router.delete('/connections/:id', deps.requireAuth, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } - // Begin audit BEFORE delete so FK(connection_id) is still valid. - // FK has ON DELETE SET NULL; complete() updates by audit_id only. - const auditId = deps.auditRepo.begin({ - action: 'ssh.connection.delete', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: userId, - actingUserId: userId, - detail: { label: conn.label, host: conn.host }, - }); - const ok = deps.connectionRepo.delete(conn.id); - if (!ok) { - deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); - jsonError(res, 404, 'not_found'); - return; - } - deps.auditRepo.complete(auditId, 'success'); - res.json({ ok: true }); - } catch (e) { - logger.warn(`[ssh:api] delete user connection failed err=${String(e)}`); - jsonError(res, 500, 'delete_failed'); - } - }); - - // POST /api/ssh/connections/:id/test — capture-only connect. Decrypts the - // key, dials the host (or asks the injected tester to do so), captures the - // host key, and stores it pending with a fresh verify token. Returns the - // fingerprint + token to the user so they can call /verify-host-key next. - router.post('/connections/:id/test', deps.requireAuth, async (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== userId && !deps.isAdmin(req)) { - jsonError(res, 403, 'owner_or_admin_only'); return; - } - let decryptedKey: Buffer | null = null; - let passphrase: Buffer | null = null; - let auditId: number | null = null; - try { - auditId = deps.auditRepo.begin({ - action: 'ssh.connection.host_key.tofu_record', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - detail: { op: 'test' }, - }); - decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc, conn.spaceId); - passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc, conn.spaceId); - const result = await deps.sshTester.test({ - connection: conn, - decryptedKey, - passphrase, - timeoutMs: testTimeoutMs, - }); - // alg_not_allowed: the observed host key uses a banned algorithm - // (e.g. ssh-rsa with SHA1). Don't store as pending — we'd never accept it. - // Surface the fingerprint so the operator can audit/replace the server's key. - if (result.verdict === 'alg_not_allowed') { - deps.auditRepo.complete(auditId, 'denied', { - verdict: result.verdict, - observedFingerprint: result.fingerprint, - hostKeyType: result.hostKeyType, - }); - res.status(502).json({ - error: 'host_key_alg_not_allowed', - verdict: result.verdict, - fingerprint: result.fingerprint, - hostKeyType: result.hostKeyType, - }); - return; - } - // Store the observation as pending for first_observe and mismatch. - // 'pass' means the key already matched the verified record — no token needed. - let token: string | null = null; - if (result.verdict === 'first_observe' || result.verdict === 'mismatch') { - const stored = deps.connectionRepo.setHostKeyPendingWithToken( - conn.id, - result.hostKeyB64, - result.fingerprint, - result.verdict === 'first_observe' ? 'tofu_record' : 'mismatch', - ); - token = stored?.token ?? null; - } - deps.auditRepo.complete(auditId, 'success', { - verdict: result.verdict, - observedFingerprint: result.fingerprint, - }); - res.json({ - verdict: result.verdict, - fingerprint: result.fingerprint, - hostKeyType: result.hostKeyType, - pendingToken: token, - }); - } catch (e) { - if (auditId !== null) { - deps.auditRepo.complete(auditId, 'failed', { err: String(e) }); - } - logger.warn(`[ssh:api] connection test failed id=${conn.id} err=${String(e)}`); - jsonError(res, 502, 'test_failed', { detail: String(e) }); - } finally { - if (decryptedKey) decryptedKey.fill(0); - if (passphrase) passphrase.fill(0); - } - }); - - // POST /api/ssh/connections/:id/verify-host-key - router.post('/connections/:id/verify-host-key', deps.requireAuth, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== userId && !deps.isAdmin(req)) { - jsonError(res, 403, 'owner_or_admin_only'); return; - } - const body = req.body ?? {}; - const fingerprint = safeFingerprint(body.fingerprint); - const token = safeUuid(body.token); - if (!fingerprint || !token) { - jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token'] }); - return; - } - const result: HostKeyVerifyResult = deps.connectionRepo.setHostKeyVerified(conn.id, token, fingerprint); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.host_key.verify', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - detail: { result, fingerprint }, - }, - result === 'verified' ? 'success' : 'failed', - ); - if (result === 'verified') { - const updated = deps.connectionRepo.resolveConnection(conn.id)!; - res.json({ ok: true, connection: presentConnection(updated) }); - } else { - jsonError(res, 409, result); - } - } catch (e) { - logger.warn(`[ssh:api] verify host key failed err=${String(e)}`); - jsonError(res, 500, 'verify_failed'); - } - }); - - // POST /api/ssh/connections/:id/replace-host-key — rotate an already-verified key. - router.post('/connections/:id/replace-host-key', deps.requireAuth, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== userId && !deps.isAdmin(req)) { - jsonError(res, 403, 'owner_or_admin_only'); return; - } - const body = req.body ?? {}; - const fingerprint = safeFingerprint(body.fingerprint); - const token = safeUuid(body.token); - const reasonErr = validateReason(body.reason); - if (!fingerprint || !token) { - jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token', 'reason'] }); - return; - } - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const result: HostKeyVerifyResult = deps.connectionRepo.replaceHostKey(conn.id, token, fingerprint); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.host_key.replace', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(body.reason), - detail: { result, fingerprint }, - }, - result === 'verified' ? 'success' : 'failed', - ); - if (result === 'verified') { - const updated = deps.connectionRepo.resolveConnection(conn.id)!; - res.json({ ok: true, connection: presentConnection(updated) }); - } else { - jsonError(res, 409, result); - } - } catch (e) { - logger.warn(`[ssh:api] replace host key failed err=${String(e)}`); - jsonError(res, 500, 'replace_failed'); - } - }); - - router.get('/connections/:id/audit', deps.requireAuth, (req, res) => { - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== userId && !deps.isAdmin(req)) { - jsonError(res, 403, 'owner_or_admin_only'); return; - } - const limit = safeLimit(req.query.limit); - const rows = deps.auditRepo.listForConnection(conn.id, limit); - res.json({ audit: rows.map(presentAuditRow) }); - } catch (e) { - logger.warn(`[ssh:api] audit list failed err=${String(e)}`); - jsonError(res, 500, 'audit_failed'); - } - }); - - // GET /api/ssh/grants/visible-to-me - router.get('/grants/visible-to-me', deps.requireAuth, (req, res) => { - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const orgIds = deps.getOrgIds(req); - const orgPh = orgIds.length > 0 ? orgIds.map(() => '?').join(',') : null; - const sql = ` - SELECT * FROM ssh_connection_grants - WHERE (subject_type = 'user' AND subject_id = ?) - ${orgPh ? `OR (subject_type = 'org' AND subject_id IN (${orgPh}))` : ''} - ORDER BY created_at DESC - `; - const params: unknown[] = [userId]; - if (orgPh) params.push(...orgIds); - const rows = deps.db.prepare(sql).all(...params) as Array<{ - id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; - piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; - reason: string; expires_at: string | null; created_at: string; - }>; - const grants = rows.map((r) => ({ - id: r.id, - connectionId: r.connection_id, - subjectType: r.subject_type, - subjectId: r.subject_id, - pieceName: r.piece_name, - appliesToAllPieces: r.applies_to_all_pieces === 1, - grantedByUserId: r.granted_by_user_id, - reason: r.reason, - expiresAt: r.expires_at, - createdAt: r.created_at, - })); - res.json({ grants }); - } catch (e) { - logger.warn(`[ssh:api] list visible grants failed err=${String(e)}`); - jsonError(res, 500, 'list_grants_failed'); - } - }); - - return router; -} - -// ────────────────────────────────────────────────────────────────────── -// Admin router — /api/ssh/admin/* -// ────────────────────────────────────────────────────────────────────── - -export function createSshAdminRouter(deps: SshApiDeps): Router { - const router = Router(); - router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); - - // GET /api/ssh/admin/connections - router.get('/connections', deps.requireAdmin, (_req, res) => { - try { - const list = deps.connectionRepo.listAll(); - res.json({ connections: list.map(presentConnection) }); - } catch (e) { - logger.warn(`[ssh:api] admin list connections failed err=${String(e)}`); - jsonError(res, 500, 'list_failed'); - } - }); - - router.get('/connections/:id', deps.requireAdmin, (req, res) => { - try { - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - let publicKey: string | null = null; - try { - publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId); - } catch (e) { - logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`); - } - res.json({ connection: presentConnection(conn), publicKey }); - } catch (e) { - logger.warn(`[ssh:api] admin get connection failed err=${String(e)}`); - jsonError(res, 500, 'get_failed'); - } - }); - - router.patch('/connections/:id/disable', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - const ok = deps.connectionRepo.disableByAdmin(conn.id, String(req.body.reason), userId); - if (!ok) { jsonError(res, 404, 'not_found'); return; } - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.disable', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(req.body.reason), - }, - 'success', - ); - const updated = deps.connectionRepo.resolveConnection(conn.id)!; - res.json({ connection: presentConnection(updated) }); - } catch (e) { - logger.warn(`[ssh:api] admin disable failed err=${String(e)}`); - jsonError(res, 500, 'disable_failed'); - } - }); - - router.patch('/connections/:id/enable', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - const ok = deps.connectionRepo.enableByAdmin(conn.id); - if (!ok) { jsonError(res, 404, 'not_found'); return; } - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.enable', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(req.body.reason), - }, - 'success', - ); - const updated = deps.connectionRepo.resolveConnection(conn.id)!; - res.json({ connection: presentConnection(updated) }); - } catch (e) { - logger.warn(`[ssh:api] admin enable failed err=${String(e)}`); - jsonError(res, 500, 'enable_failed'); - } - }); - - router.delete('/connections/:id', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - const auditId = deps.auditRepo.begin({ - action: 'ssh.connection.delete', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(req.body.reason), - }); - const ok = deps.connectionRepo.delete(conn.id); - if (!ok) { - deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); - jsonError(res, 404, 'not_found'); - return; - } - deps.auditRepo.complete(auditId, 'success'); - res.json({ ok: true }); - } catch (e) { - logger.warn(`[ssh:api] admin delete failed err=${String(e)}`); - jsonError(res, 500, 'delete_failed'); - } - }); - - router.post('/connections/:id/force-unlock', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const decision = deps.forceUnlockLimiter.check(userId); - if (!decision.allowed) { - if (decision.retryAfterSeconds !== undefined) { - res.setHeader('Retry-After', String(decision.retryAfterSeconds)); - } - jsonError(res, 429, 'rate_limited', { retryAfterSeconds: decision.retryAfterSeconds }); - return; - } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - const removed = deps.abuseRepo.reset(`conn:${conn.id}`); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.abuse.unlock_manual', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(req.body.reason), - detail: { removed }, - }, - 'success', - ); - res.json({ ok: true, removed }); - } catch (e) { - logger.warn(`[ssh:api] admin force-unlock failed err=${String(e)}`); - jsonError(res, 500, 'unlock_failed'); - } - }); - - // POST /api/ssh/admin/globals — create a global connection (owner_id=NULL). - router.post('/globals', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const body = req.body ?? {}; - const label = safeString(body.label, 200); - const host = safeString(body.host, 255); - const port = safePort(body.port); - const username = safeString(body.username, 64); - const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); - const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); - const allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; - const allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; - - // remote_path_prefix is required UNLESS allow_remote_unrestricted is set. - let remotePathPrefix: string | null; - if (allowRemoteUnrestricted) { - // Stored prefix is '/' (sandbox-effectively-disabled). - remotePathPrefix = '/'; - } else { - remotePathPrefix = safePathPrefix(body.remotePathPrefix); - } - - // Keypair source: 'provided' (admin uploads PEM) or 'generate' (orchestrator - // creates a fresh keypair; the public key is returned exactly once). - const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; - let pemBuf: Buffer; - let passBuf: Buffer | null; - if (keypairSource === 'generate') { - const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; - const generated = deps.generateKeypair(keyType); - pemBuf = generated.privateKeyPem; - passBuf = null; - } else { - const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; - const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; - if (!privateKey) { - jsonError(res, 400, 'invalid_input', { - required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], - }); - return; - } - pemBuf = Buffer.from(privateKey, 'utf8'); - passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; - } - - if (!label || !host || port === null || !username || !remotePathPrefix) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_input', { - required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], - }); - return; - } - if (denyPatterns === null) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_command_deny_patterns'); return; - } - if (allowPatterns === null) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - jsonError(res, 400, 'invalid_command_allow_patterns'); return; - } - - let encrypted: SshEncryptResult; - try { - encrypted = deps.encryptKeyMaterial(null, pemBuf, passBuf); - } catch (e) { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - logger.warn(`[ssh:api] admin global encrypt failed err=${String(e)}`); - jsonError(res, 500, 'encrypt_failed'); - return; - } - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - - try { - const conn = deps.connectionRepo.create({ - ownerId: null, - label, host, port, username, - privateKeyEnc: encrypted.blob, - passphraseEnc: encrypted.passphraseBlob, - keyVersion: encrypted.keyVersion, - keyFingerprint: encrypted.fingerprint, - remotePathPrefix, - allowRemoteUnrestricted, - allowPrivateAddresses, - commandDenyPatterns: denyPatterns ?? null, - commandAllowPatterns: allowPatterns ?? null, - }); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.upsert', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: null, - actingUserId: userId, - reason: String(body.reason), - detail: { op: 'create', scope: 'global', allowRemoteUnrestricted, allowPrivateAddresses, keypairSource }, - }, - 'success', - ); - res.status(201).json({ - connection: presentConnection(conn), - publicKey: encrypted.publicKey, - }); - } catch (e) { - logger.warn(`[ssh:api] admin global create failed err=${String(e)}`); - jsonError(res, 500, 'create_failed'); - } - }); - - router.patch('/globals/:id', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } - const body = req.body ?? {}; - const patch: Parameters[1] = {}; - if (body.label !== undefined) { - const v = safeString(body.label, 200); - if (v === null) { jsonError(res, 400, 'invalid_label'); return; } - patch.label = v; - } - if (body.host !== undefined) { - const v = safeString(body.host, 255); - if (v === null) { jsonError(res, 400, 'invalid_host'); return; } - patch.host = v; - } - if (body.port !== undefined) { - const v = safePort(body.port); - if (v === null) { jsonError(res, 400, 'invalid_port'); return; } - patch.port = v; - } - if (body.username !== undefined) { - const v = safeString(body.username, 64); - if (v === null) { jsonError(res, 400, 'invalid_username'); return; } - patch.username = v; - } - if (body.remotePathPrefix !== undefined) { - const v = safePathPrefix(body.remotePathPrefix); - if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } - patch.remotePathPrefix = v; - } - if (body.commandDenyPatterns !== undefined) { - const v = safeOptionalString(body.commandDenyPatterns, 4096); - if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } - patch.commandDenyPatterns = v ?? null; - } - if (body.commandAllowPatterns !== undefined) { - const v = safeOptionalString(body.commandAllowPatterns, 4096); - if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } - patch.commandAllowPatterns = v ?? null; - } - if (body.allowRemoteUnrestricted !== undefined) { - patch.allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; - } - if (body.allowPrivateAddresses !== undefined) { - patch.allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; - } - if (typeof body.privateKeyPem === 'string') { - let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); - let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; - try { - const enc = deps.encryptKeyMaterial(null, pemBuf, passBuf); - patch.privateKeyEnc = enc.blob; - patch.passphraseEnc = enc.passphraseBlob; - patch.keyVersion = enc.keyVersion; - patch.keyFingerprint = enc.fingerprint; - } finally { - pemBuf.fill(0); - if (passBuf) passBuf.fill(0); - } - } - const ok = deps.connectionRepo.update(conn.id, patch); - if (!ok) { jsonError(res, 404, 'not_found'); return; } - deps.auditRepo.beginAndComplete( - { - action: 'ssh.connection.upsert', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: null, - actingUserId: userId, - reason: String(req.body.reason), - detail: { op: 'update', scope: 'global', fields: Object.keys(patch) }, - }, - 'success', - ); - const updated = deps.connectionRepo.resolveConnection(conn.id)!; - res.json({ connection: presentConnection(updated) }); - } catch (e) { - logger.warn(`[ssh:api] admin global patch failed err=${String(e)}`); - jsonError(res, 500, 'update_failed'); - } - }); - - router.delete('/globals/:id', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const conn = deps.connectionRepo.resolveConnection(req.params.id); - if (!conn) { jsonError(res, 404, 'not_found'); return; } - if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } - const auditId = deps.auditRepo.begin({ - action: 'ssh.connection.delete', - entityType: 'ssh_connection', - entityId: conn.id, - connectionId: conn.id, - ownerId: null, - actingUserId: userId, - reason: String(req.body.reason), - }); - const ok = deps.connectionRepo.delete(conn.id); - if (!ok) { - deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); - jsonError(res, 404, 'not_found'); - return; - } - deps.auditRepo.complete(auditId, 'success'); - res.json({ ok: true }); - } catch (e) { - logger.warn(`[ssh:api] admin global delete failed err=${String(e)}`); - jsonError(res, 500, 'delete_failed'); - } - }); - - router.get('/grants', deps.requireAdmin, (req, res) => { - try { - const limit = safeLimit(req.query.limit, 100, 1000); - const rows = deps.db.prepare( - `SELECT * FROM ssh_connection_grants ORDER BY created_at DESC LIMIT ?`, - ).all(limit) as Array<{ - id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; - piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; - reason: string; expires_at: string | null; created_at: string; - }>; - const grants = rows.map((r) => ({ - id: r.id, - connectionId: r.connection_id, - subjectType: r.subject_type, - subjectId: r.subject_id, - pieceName: r.piece_name, - appliesToAllPieces: r.applies_to_all_pieces === 1, - grantedByUserId: r.granted_by_user_id, - reason: r.reason, - expiresAt: r.expires_at, - createdAt: r.created_at, - })); - res.json({ grants }); - } catch (e) { - logger.warn(`[ssh:api] admin list grants failed err=${String(e)}`); - jsonError(res, 500, 'list_grants_failed'); - } - }); - - router.post('/grants', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const body = req.body ?? {}; - const reasonErr = validateReason(body.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const connectionId = safeUuid(body.connectionId); - const subjectType = safeSubjectType(body.subjectType); - const subjectId = safeString(body.subjectId, 128); - const appliesToAllPieces = body.appliesToAllPieces === true || body.appliesToAllPieces === 1; - const pieceName = safeOptionalString(body.pieceName, 64); - const expiresAt = safeExpiresAt(body.expiresAt); - if (!connectionId || !subjectType || !subjectId) { - jsonError(res, 400, 'invalid_input', { - required: ['connectionId', 'subjectType ("user"|"org")', 'subjectId', 'reason'], - }); - return; - } - if (pieceName === null) { jsonError(res, 400, 'invalid_piece_name'); return; } - if (expiresAt === null) { jsonError(res, 400, 'invalid_expires_at'); return; } - if (!appliesToAllPieces && (!pieceName || pieceName.length === 0)) { - jsonError(res, 400, 'piece_name_required_unless_applies_to_all'); return; - } - if (appliesToAllPieces && pieceName !== undefined && pieceName.length > 0) { - jsonError(res, 400, 'piece_name_conflicts_with_applies_to_all'); return; - } - // Connection must exist before granting. - const conn = deps.connectionRepo.resolveConnection(connectionId); - if (!conn) { jsonError(res, 404, 'connection_not_found'); return; } - let grant: SshGrant; - try { - grant = deps.grantsRepo.create({ - connectionId, - subjectType, - subjectId, - pieceName: appliesToAllPieces ? null : (pieceName ?? null), - appliesToAllPieces, - grantedByUserId: userId, - reason: String(body.reason), - expiresAt, - }); - } catch (e) { - jsonError(res, 400, 'grant_create_failed', { detail: String(e) }); return; - } - deps.auditRepo.beginAndComplete( - { - action: 'ssh.grant.create', - entityType: 'ssh_grant', - entityId: grant.id, - connectionId: conn.id, - ownerId: conn.ownerId, - actingUserId: userId, - reason: String(body.reason), - detail: { subjectType, subjectId, pieceName: grant.pieceName, appliesToAllPieces }, - }, - 'success', - ); - res.status(201).json({ grant: presentGrant(grant) }); - } catch (e) { - logger.warn(`[ssh:api] admin grant create failed err=${String(e)}`); - jsonError(res, 500, 'grant_create_failed'); - } - }); - - router.delete('/grants/:id', deps.requireAdmin, (req, res) => { - if (maintenance503(deps.maintenance, res)) return; - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - const grant = deps.grantsRepo.getById(req.params.id); - if (!grant) { jsonError(res, 404, 'not_found'); return; } - const ok = deps.grantsRepo.delete(grant.id); - if (!ok) { jsonError(res, 404, 'not_found'); return; } - // Kick any active console-WS viewers whose access depended on this grant. - // user-subject grants map 1:1 to a single userId; org-subject grants are - // deferred (next attach will be denied; existing viewers stay until the - // session ends or the org-member's WS reconnects). - let kicked: number | void = 0; - if (grant.subjectType === 'user' && deps.onAccessRevoked) { - kicked = deps.onAccessRevoked({ connectionId: grant.connectionId, userId: grant.subjectId }); - } - deps.auditRepo.beginAndComplete( - { - action: 'ssh.grant.delete', - entityType: 'ssh_grant', - entityId: grant.id, - connectionId: grant.connectionId, - actingUserId: userId, - reason: String(req.body.reason), - detail: { - subjectType: grant.subjectType, - subjectId: grant.subjectId, - pieceName: grant.pieceName, - viewersKicked: typeof kicked === 'number' ? kicked : 0, - }, - }, - 'success', - ); - res.json({ ok: true }); - } catch (e) { - logger.warn(`[ssh:api] admin grant delete failed err=${String(e)}`); - jsonError(res, 500, 'grant_delete_failed'); - } - }); - - // POST /api/ssh/admin/rotate-master-key — stub for v1 (Phase 5). - // Sets the maintenance flag and audits the intent. The actual DEK re-wrap - // job is deferred to a follow-up PR per Phase 5 design note (line 728). - // Returns 501 in the response body but 202 status so the UI can show a - // banner; UI Phase 6 will key off the maintenance snapshot. - router.post('/rotate-master-key', deps.requireAdmin, (req, res) => { - try { - const userId = deps.getUserId(req); - if (!userId) { jsonError(res, 401, 'unauthorized'); return; } - const reasonErr = validateReason(req.body?.reason); - if (reasonErr) { jsonError(res, 400, reasonErr); return; } - if (deps.maintenance.isActive()) { - jsonError(res, 409, 'already_in_progress', { snapshot: deps.maintenance.snapshot() }); - return; - } - const jobId = `rotate-${Date.now().toString(36)}`; - deps.maintenance.enter(String(req.body.reason), jobId); - deps.auditRepo.beginAndComplete( - { - action: 'ssh.master_key.rotate.start', - actingUserId: userId, - reason: String(req.body.reason), - detail: { jobId, status: 'stub', note: 'rotation job not implemented in v1; only maintenance flag set' }, - }, - 'success', - ); - res.status(202).json({ - jobId, - status: 'maintenance_set', - detail: 'maintenance flag is now active; actual DEK re-wrap is not implemented in v1', - notImplemented: true, - }); - } catch (e) { - logger.warn(`[ssh:api] admin rotate-master-key failed err=${String(e)}`); - jsonError(res, 500, 'rotate_failed'); - } - }); - - router.get('/rotate-master-key/:jobId', deps.requireAdmin, (req, res) => { - try { - const snap = deps.maintenance.snapshot(); - if (!snap.active || snap.jobId !== req.params.jobId) { - jsonError(res, 404, 'not_found_or_completed', { snapshot: snap }); - return; - } - res.json({ - jobId: snap.jobId, - status: 'in_progress', - startedAt: snap.enteredAt, - progress: { note: 'v1 stub: no rewrap performed' }, - notImplemented: true, - }); - } catch (e) { - logger.warn(`[ssh:api] admin rotate-master-key get failed err=${String(e)}`); - jsonError(res, 500, 'rotate_get_failed'); - } - }); - - // GET /api/ssh/admin/audit — cross-user audit query. - router.get('/audit', deps.requireAdmin, (req, res) => { - try { - const limit = safeLimit(req.query.limit, 100, 1000); - const action = typeof req.query.action === 'string' ? req.query.action : null; - const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : null; - const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : null; - const outcome = typeof req.query.outcome === 'string' ? req.query.outcome : null; - - const where: string[] = []; - const params: unknown[] = []; - if (action) { where.push('action = ?'); params.push(action); } - if (ownerId) { where.push('owner_id = ?'); params.push(ownerId); } - if (connectionId) { where.push('connection_id = ?'); params.push(connectionId); } - if (outcome) { where.push('outcome = ?'); params.push(outcome); } - const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; - const sql = `SELECT * FROM ssh_audit_log ${whereClause} ORDER BY started_at DESC LIMIT ?`; - params.push(limit); - const rows = deps.db.prepare(sql).all(...params) as Array<{ - id: number; action: string; entity_type: string | null; entity_id: string | null; - connection_id: string | null; owner_id: string | null; acting_user_id: string | null; - job_id: string | null; piece_name: string | null; outcome: string; - reason: string | null; detail: string | null; started_at: string; completed_at: string | null; - }>; - const audit = rows.map((r) => ({ - id: r.id, - action: r.action, - entityType: r.entity_type, - entityId: r.entity_id, - connectionId: r.connection_id, - ownerId: r.owner_id, - actingUserId: r.acting_user_id, - jobId: r.job_id, - pieceName: r.piece_name, - outcome: r.outcome, - reason: r.reason, - detail: r.detail ? JSON.parse(r.detail) : null, - startedAt: r.started_at, - completedAt: r.completed_at, - })); - res.json({ audit }); - } catch (e) { - logger.warn(`[ssh:api] admin audit list failed err=${String(e)}`); - jsonError(res, 500, 'audit_failed'); - } - }); - - return router; -} +export type { + SshTesterArgs, + SshTesterVerdict, + SshTesterResult, + SshTester, + SshEncryptResult, + SshApiDeps, +} from './ssh-api-shared.js'; +export { createSshUserRouter } from './ssh-user-api.js'; +export { createSshAdminRouter } from './ssh-admin-api.js'; diff --git a/src/bridge/ssh-user-api.ts b/src/bridge/ssh-user-api.ts new file mode 100644 index 0000000..264083a --- /dev/null +++ b/src/bridge/ssh-user-api.ts @@ -0,0 +1,589 @@ +/** + * SSH HTTP layer — user router (`/api/ssh/connections` + `/api/ssh/grants`). + * + * Extracted verbatim from src/bridge/ssh-api.ts (split refactor). Shared + * types / helpers live in ssh-api-shared.ts; the admin router lives in + * ssh-admin-api.ts. Mounted from src/bridge/server.ts (via ssh-subsystem) + * ONLY when `ssh.enabled=true` AND `MCP_ENCRYPTION_KEY` is configured. + * See the design conventions in the ssh-api.ts barrel header. + */ + +import { Router, type Request } from 'express'; + +import type { SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js'; +import type { SshGrantSubjectType } from '../ssh/grants-repo.js'; +import { logger } from '../logger.js'; +import { + type SshApiDeps, + type SshEncryptResult, + validateReason, + maintenance503, + safePort, + safeString, + safeOptionalString, + safePathPrefix, + safeFingerprint, + safeUuid, + readSpaceId, + presentConnection, + presentAuditRow, + safeLimit, + jsonError, + makeSshLocalUserMiddleware, +} from './ssh-api-shared.js'; + +export function createSshUserRouter(deps: SshApiDeps): Router { + const router = Router(); + const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000; + router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); + + // GET /api/ssh/connections — list owned (and globals visible via grant). + // For Phase 5 we return: + // - all owned (owner_id == userId) + // - all globals (owner_id IS NULL) — visibility is enforced at tool-call + // time via accessResolver, so listing globals here is informational. + // Admins see all via /api/ssh/admin/connections (separate endpoint). + router.get('/connections', deps.requireAuth, async (req, res) => { + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + + // Space-scoped listing (spec §11): ?spaceId= (or ?space_id=) returns + // ONLY that space's connections, visibility-checked against the viewer. + // Omitting it keeps the legacy per-user list (owned + globals). + const spaceId = readSpaceId(req.query as Record); + if (typeof spaceId === 'string') { + if (!deps.getSpace) { jsonError(res, 400, 'space_scoped_listing_unavailable'); return; } + const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user); + if (!space) { jsonError(res, 404, 'unknown_or_inaccessible_space'); return; } + const scoped = deps.connectionRepo.listBySpace(spaceId); + res.json({ connections: scoped.map(presentConnection) }); + return; + } + + const owned = deps.connectionRepo.listOwned(userId); + const all = deps.connectionRepo.listAll(); + const globals = all.filter((c) => c.ownerId === null); + // Deduplicate (owned should never overlap globals; safety net). + const seen = new Set(owned.map((c) => c.id)); + const list = [...owned]; + for (const g of globals) { + if (!seen.has(g.id)) { list.push(g); seen.add(g.id); } + } + res.json({ connections: list.map(presentConnection) }); + } catch (e) { + logger.warn(`[ssh:api] list user connections failed err=${String(e)}`); + jsonError(res, 500, 'list_failed'); + } + }); + + // POST /api/ssh/connections — create user-owned. + router.post('/connections', deps.requireAuth, async (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const body = req.body ?? {}; + const label = safeString(body.label, 200); + const host = safeString(body.host, 255); + const port = safePort(body.port); + const username = safeString(body.username, 64); + const remotePathPrefix = safePathPrefix(body.remotePathPrefix); + const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); + const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); + // User-owned create rejects admin-only flags up front. + if (body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1) { + jsonError(res, 403, 'allow_remote_unrestricted_admin_only'); return; + } + if (body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1) { + jsonError(res, 403, 'allow_private_addresses_admin_only'); return; + } + + // Keypair source: 'provided' (user uploads PEM, default) or + // 'generate' (orchestrator creates a fresh keypair). In the latter case + // the user gets the public key back exactly once in this response. + const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; + let pemBuf: Buffer; + let passBuf: Buffer | null; + if (keypairSource === 'generate') { + const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; + const generated = deps.generateKeypair(keyType); + pemBuf = generated.privateKeyPem; + passBuf = null; + } else { + const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; + const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; + if (!privateKey) { + jsonError(res, 400, 'invalid_input', { + required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], + }); + return; + } + pemBuf = Buffer.from(privateKey, 'utf8'); + passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; + } + + if (!label || !host || port === null || !username || !remotePathPrefix) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_input', { + required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], + }); + return; + } + if (denyPatterns === null) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_command_deny_patterns'); return; + } + if (allowPatterns === null) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'invalid_command_allow_patterns'); return; + } + + // Per-space scoping (spec §11). When a space is supplied, authorize it + // against the requesting viewer via getSpace BEFORE encrypting/storing. + // owner_id stays = userId (created_by); space_id gates who may USE the + // connection at runtime (space membership) AND — Space-as-Principal P2 — + // selects the SPACE DEK for at-rest encryption so the key survives member + // turnover. Omitting it = legacy space-less (owner-DEK) connection. + const spaceId = readSpaceId(body as Record); + if (typeof spaceId === 'string') { + if (!deps.getSpace) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 400, 'space_scoped_create_unavailable'); return; + } + const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user); + if (!space) { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + jsonError(res, 404, 'unknown_or_inaccessible_space'); return; + } + } + + const createSpaceId = typeof spaceId === 'string' ? spaceId : null; + let encrypted: SshEncryptResult; + try { + encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf, createSpaceId); + } finally { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + } + const conn = deps.connectionRepo.create({ + ownerId: userId, + spaceId: typeof spaceId === 'string' ? spaceId : null, + label, host, port, username, + privateKeyEnc: encrypted.blob, + passphraseEnc: encrypted.passphraseBlob, + keyVersion: encrypted.keyVersion, + keyFingerprint: encrypted.fingerprint, + remotePathPrefix, + commandDenyPatterns: denyPatterns ?? null, + commandAllowPatterns: allowPatterns ?? null, + }); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.upsert', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: userId, + actingUserId: userId, + detail: { op: 'create', label: conn.label, host: conn.host, port: conn.port, keypairSource }, + }, + 'success', + ); + res.status(201).json({ + connection: presentConnection(conn), + publicKey: encrypted.publicKey, + }); + } catch (e) { + logger.warn(`[ssh:api] create user connection failed err=${String(e)}`); + jsonError(res, 500, 'create_failed'); + } + }); + + router.get('/connections/:id', deps.requireAuth, (req, res) => { + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + // Visibility: owner, admin, or global (info-only). Other users see 404 + // (do not leak existence of someone else's user-owned connection). + if (conn.ownerId !== null && conn.ownerId !== userId && !deps.isAdmin(req)) { + jsonError(res, 404, 'not_found'); return; + } + let publicKey: string | null = null; + try { + publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId); + } catch (e) { + // Don't fail the whole response if key derivation fails (e.g. corrupt + // blob, missing passphrase) — return the connection without it and log. + logger.warn(`[ssh:api] derive public key failed id=${conn.id} err=${String(e)}`); + } + res.json({ connection: presentConnection(conn), publicKey }); + } catch (e) { + logger.warn(`[ssh:api] get user connection failed err=${String(e)}`); + jsonError(res, 500, 'get_failed'); + } + }); + + router.patch('/connections/:id', deps.requireAuth, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + // Only owner may PATCH user-owned. Admin PATCHes globals via admin route. + if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } + const body = req.body ?? {}; + + const patch: Parameters[1] = {}; + if (body.label !== undefined) { + const v = safeString(body.label, 200); + if (v === null) { jsonError(res, 400, 'invalid_label'); return; } + patch.label = v; + } + if (body.host !== undefined) { + const v = safeString(body.host, 255); + if (v === null) { jsonError(res, 400, 'invalid_host'); return; } + patch.host = v; + } + if (body.port !== undefined) { + const v = safePort(body.port); + if (v === null) { jsonError(res, 400, 'invalid_port'); return; } + patch.port = v; + } + if (body.username !== undefined) { + const v = safeString(body.username, 64); + if (v === null) { jsonError(res, 400, 'invalid_username'); return; } + patch.username = v; + } + if (body.remotePathPrefix !== undefined) { + const v = safePathPrefix(body.remotePathPrefix); + if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } + patch.remotePathPrefix = v; + } + if (body.commandDenyPatterns !== undefined) { + const v = safeOptionalString(body.commandDenyPatterns, 4096); + if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } + patch.commandDenyPatterns = v ?? null; + } + if (body.commandAllowPatterns !== undefined) { + const v = safeOptionalString(body.commandAllowPatterns, 4096); + if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } + patch.commandAllowPatterns = v ?? null; + } + // Users cannot toggle admin-only flags. + if (body.allowRemoteUnrestricted !== undefined || body.allowPrivateAddresses !== undefined) { + jsonError(res, 403, 'admin_only_flag'); return; + } + // Key rotation (privateKeyPem in patch). Re-encrypt and bump key_version. + if (typeof body.privateKeyPem === 'string') { + let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); + let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; + try { + // Re-encrypt under the connection's current DEK domain (space DEK when + // space-owned) so a key rotation keeps the space-ownership property. + const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf, conn.spaceId); + patch.privateKeyEnc = enc.blob; + patch.passphraseEnc = enc.passphraseBlob; + patch.keyVersion = enc.keyVersion; + patch.keyFingerprint = enc.fingerprint; + } finally { + pemBuf.fill(0); + if (passBuf) passBuf.fill(0); + } + } + const ok = deps.connectionRepo.update(conn.id, patch); + if (!ok) { jsonError(res, 404, 'not_found'); return; } + const updated = deps.connectionRepo.resolveConnection(conn.id); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.upsert', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: userId, + actingUserId: userId, + detail: { op: 'update', fields: Object.keys(patch) }, + }, + 'success', + ); + res.json({ connection: presentConnection(updated!) }); + } catch (e) { + logger.warn(`[ssh:api] patch user connection failed err=${String(e)}`); + jsonError(res, 500, 'update_failed'); + } + }); + + router.delete('/connections/:id', deps.requireAuth, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } + // Begin audit BEFORE delete so FK(connection_id) is still valid. + // FK has ON DELETE SET NULL; complete() updates by audit_id only. + const auditId = deps.auditRepo.begin({ + action: 'ssh.connection.delete', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: userId, + actingUserId: userId, + detail: { label: conn.label, host: conn.host }, + }); + const ok = deps.connectionRepo.delete(conn.id); + if (!ok) { + deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); + jsonError(res, 404, 'not_found'); + return; + } + deps.auditRepo.complete(auditId, 'success'); + res.json({ ok: true }); + } catch (e) { + logger.warn(`[ssh:api] delete user connection failed err=${String(e)}`); + jsonError(res, 500, 'delete_failed'); + } + }); + + // POST /api/ssh/connections/:id/test — capture-only connect. Decrypts the + // key, dials the host (or asks the injected tester to do so), captures the + // host key, and stores it pending with a fresh verify token. Returns the + // fingerprint + token to the user so they can call /verify-host-key next. + router.post('/connections/:id/test', deps.requireAuth, async (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== userId && !deps.isAdmin(req)) { + jsonError(res, 403, 'owner_or_admin_only'); return; + } + let decryptedKey: Buffer | null = null; + let passphrase: Buffer | null = null; + let auditId: number | null = null; + try { + auditId = deps.auditRepo.begin({ + action: 'ssh.connection.host_key.tofu_record', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + detail: { op: 'test' }, + }); + decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc, conn.spaceId); + passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc, conn.spaceId); + const result = await deps.sshTester.test({ + connection: conn, + decryptedKey, + passphrase, + timeoutMs: testTimeoutMs, + }); + // alg_not_allowed: the observed host key uses a banned algorithm + // (e.g. ssh-rsa with SHA1). Don't store as pending — we'd never accept it. + // Surface the fingerprint so the operator can audit/replace the server's key. + if (result.verdict === 'alg_not_allowed') { + deps.auditRepo.complete(auditId, 'denied', { + verdict: result.verdict, + observedFingerprint: result.fingerprint, + hostKeyType: result.hostKeyType, + }); + res.status(502).json({ + error: 'host_key_alg_not_allowed', + verdict: result.verdict, + fingerprint: result.fingerprint, + hostKeyType: result.hostKeyType, + }); + return; + } + // Store the observation as pending for first_observe and mismatch. + // 'pass' means the key already matched the verified record — no token needed. + let token: string | null = null; + if (result.verdict === 'first_observe' || result.verdict === 'mismatch') { + const stored = deps.connectionRepo.setHostKeyPendingWithToken( + conn.id, + result.hostKeyB64, + result.fingerprint, + result.verdict === 'first_observe' ? 'tofu_record' : 'mismatch', + ); + token = stored?.token ?? null; + } + deps.auditRepo.complete(auditId, 'success', { + verdict: result.verdict, + observedFingerprint: result.fingerprint, + }); + res.json({ + verdict: result.verdict, + fingerprint: result.fingerprint, + hostKeyType: result.hostKeyType, + pendingToken: token, + }); + } catch (e) { + if (auditId !== null) { + deps.auditRepo.complete(auditId, 'failed', { err: String(e) }); + } + logger.warn(`[ssh:api] connection test failed id=${conn.id} err=${String(e)}`); + jsonError(res, 502, 'test_failed', { detail: String(e) }); + } finally { + if (decryptedKey) decryptedKey.fill(0); + if (passphrase) passphrase.fill(0); + } + }); + + // POST /api/ssh/connections/:id/verify-host-key + router.post('/connections/:id/verify-host-key', deps.requireAuth, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== userId && !deps.isAdmin(req)) { + jsonError(res, 403, 'owner_or_admin_only'); return; + } + const body = req.body ?? {}; + const fingerprint = safeFingerprint(body.fingerprint); + const token = safeUuid(body.token); + if (!fingerprint || !token) { + jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token'] }); + return; + } + const result: HostKeyVerifyResult = deps.connectionRepo.setHostKeyVerified(conn.id, token, fingerprint); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.host_key.verify', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + detail: { result, fingerprint }, + }, + result === 'verified' ? 'success' : 'failed', + ); + if (result === 'verified') { + const updated = deps.connectionRepo.resolveConnection(conn.id)!; + res.json({ ok: true, connection: presentConnection(updated) }); + } else { + jsonError(res, 409, result); + } + } catch (e) { + logger.warn(`[ssh:api] verify host key failed err=${String(e)}`); + jsonError(res, 500, 'verify_failed'); + } + }); + + // POST /api/ssh/connections/:id/replace-host-key — rotate an already-verified key. + router.post('/connections/:id/replace-host-key', deps.requireAuth, (req, res) => { + if (maintenance503(deps.maintenance, res)) return; + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== userId && !deps.isAdmin(req)) { + jsonError(res, 403, 'owner_or_admin_only'); return; + } + const body = req.body ?? {}; + const fingerprint = safeFingerprint(body.fingerprint); + const token = safeUuid(body.token); + const reasonErr = validateReason(body.reason); + if (!fingerprint || !token) { + jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token', 'reason'] }); + return; + } + if (reasonErr) { jsonError(res, 400, reasonErr); return; } + const result: HostKeyVerifyResult = deps.connectionRepo.replaceHostKey(conn.id, token, fingerprint); + deps.auditRepo.beginAndComplete( + { + action: 'ssh.connection.host_key.replace', + entityType: 'ssh_connection', + entityId: conn.id, + connectionId: conn.id, + ownerId: conn.ownerId, + actingUserId: userId, + reason: String(body.reason), + detail: { result, fingerprint }, + }, + result === 'verified' ? 'success' : 'failed', + ); + if (result === 'verified') { + const updated = deps.connectionRepo.resolveConnection(conn.id)!; + res.json({ ok: true, connection: presentConnection(updated) }); + } else { + jsonError(res, 409, result); + } + } catch (e) { + logger.warn(`[ssh:api] replace host key failed err=${String(e)}`); + jsonError(res, 500, 'replace_failed'); + } + }); + + router.get('/connections/:id/audit', deps.requireAuth, (req, res) => { + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const conn = deps.connectionRepo.resolveConnection(req.params.id); + if (!conn) { jsonError(res, 404, 'not_found'); return; } + if (conn.ownerId !== userId && !deps.isAdmin(req)) { + jsonError(res, 403, 'owner_or_admin_only'); return; + } + const limit = safeLimit(req.query.limit); + const rows = deps.auditRepo.listForConnection(conn.id, limit); + res.json({ audit: rows.map(presentAuditRow) }); + } catch (e) { + logger.warn(`[ssh:api] audit list failed err=${String(e)}`); + jsonError(res, 500, 'audit_failed'); + } + }); + + // GET /api/ssh/grants/visible-to-me + router.get('/grants/visible-to-me', deps.requireAuth, (req, res) => { + try { + const userId = deps.getUserId(req); + if (!userId) { jsonError(res, 401, 'unauthorized'); return; } + const orgIds = deps.getOrgIds(req); + const orgPh = orgIds.length > 0 ? orgIds.map(() => '?').join(',') : null; + const sql = ` + SELECT * FROM ssh_connection_grants + WHERE (subject_type = 'user' AND subject_id = ?) + ${orgPh ? `OR (subject_type = 'org' AND subject_id IN (${orgPh}))` : ''} + ORDER BY created_at DESC + `; + const params: unknown[] = [userId]; + if (orgPh) params.push(...orgIds); + const rows = deps.db.prepare(sql).all(...params) as Array<{ + id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; + piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; + reason: string; expires_at: string | null; created_at: string; + }>; + const grants = rows.map((r) => ({ + id: r.id, + connectionId: r.connection_id, + subjectType: r.subject_type, + subjectId: r.subject_id, + pieceName: r.piece_name, + appliesToAllPieces: r.applies_to_all_pieces === 1, + grantedByUserId: r.granted_by_user_id, + reason: r.reason, + expiresAt: r.expires_at, + createdAt: r.created_at, + })); + res.json({ grants }); + } catch (e) { + logger.warn(`[ssh:api] list visible grants failed err=${String(e)}`); + jsonError(res, 500, 'list_grants_failed'); + } + }); + + return router; +} diff --git a/src/bridge/ui-static.ts b/src/bridge/ui-static.ts new file mode 100644 index 0000000..35f09de --- /dev/null +++ b/src/bridge/ui-static.ts @@ -0,0 +1,36 @@ +import express, { Request, Response } from 'express'; +import { existsSync } from 'fs'; +import { join, resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filenameUiStatic = fileURLToPath(import.meta.url); +const __dirnameUiStatic = dirname(__filenameUiStatic); + +/** + * Root redirect + built-UI static hosting + the headless app-harness page, + * extracted verbatim from createCoreServer (bridge/server.ts). This module + * lives in the same directory as server.ts, so the ../../ui/dist resolution + * is identical at runtime. + */ +export function mountUiStatic(app: express.Application): void { + // Redirect root to UI + app.get('/', (_req: Request, res: Response) => { + res.redirect('/ui'); + }); + + const uiDistPath = resolve(join(__dirnameUiStatic, '../../ui/dist')); + if (existsSync(uiDistPath)) { + app.use('/ui', express.static(uiDistPath)); + app.get('/ui/*', (_req, res) => { + res.sendFile(join(uiDistPath, 'index.html')); + }); + // Headless test-harness page for workspace-app E2E (Task 5/6). + // Must be registered BEFORE any SPA catch-all and BEFORE space-api routes + // so that the app-harness auth middleware (mounted below) sees the request. + // The harness JS/CSS chunks are served by the existing /ui static mount + // because Vite builds them with base: '/ui/' → /ui/assets/… paths. + app.get('/app-harness', (_req, res) => { + res.sendFile(join(uiDistPath, 'app-harness.html')); + }); + } +} diff --git a/src/config.a2a-limits.test.ts b/src/config.a2a-limits.test.ts new file mode 100644 index 0000000..9cf7287 --- /dev/null +++ b/src/config.a2a-limits.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { transformKeys } from './config.js'; + +describe('a2a.limits transform', () => { + it('maps all six snake_case keys to camelCase', () => { + const out = transformKeys({ + a2a: { + limits: { + rate_per_minute: 42, + max_concurrent_tasks: 3, + max_payload_bytes: 1024, + max_stream_seconds: 120, + skill_budget_per_hour: 50, + max_concurrent_resubscribe: 4, + }, + }, + }) as any; + expect(out.a2a.limits.ratePerMinute).toBe(42); + expect(out.a2a.limits.maxConcurrentTasks).toBe(3); + expect(out.a2a.limits.maxPayloadBytes).toBe(1024); + expect(out.a2a.limits.maxStreamSeconds).toBe(120); + expect(out.a2a.limits.skillBudgetPerHour).toBe(50); + expect(out.a2a.limits.maxConcurrentResubscribe).toBe(4); + }); +}); diff --git a/src/config.ts b/src/config.ts index 2e61a75..5e71fc1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -550,6 +550,14 @@ export interface AppConfig { enabled?: boolean; issuer?: string; resourceAudience?: string; + limits?: { + ratePerMinute?: number; + maxConcurrentTasks?: number; + maxPayloadBytes?: number; + maxStreamSeconds?: number; + skillBudgetPerHour?: number; + maxConcurrentResubscribe?: number; + }; }; } @@ -624,7 +632,7 @@ function toCamel(s: string): string { return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); } -function transformKeys(obj: unknown): unknown { +export function transformKeys(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(transformKeys); if (obj !== null && typeof obj === 'object') { return Object.fromEntries( diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 98ca280..ca07ae8 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -69,6 +69,29 @@ export function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status); `); + // Package requests: agent-declared (RequestPackage) requests to install a + // Python wheel into the task's space overlay, pending a task-write approver. + // Keep in sync with schema.sql (fresh path) and Repository.initSchema. + db.exec(` + CREATE TABLE IF NOT EXISTS package_requests ( + id TEXT PRIMARY KEY, + task_id TEXT, + job_id TEXT, + space_id TEXT, + piece_name TEXT, + movement_name TEXT, + spec TEXT NOT NULL, + normalized_name TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + decided_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_package_requests_task ON package_requests (task_id); + CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status); + `); + // A2A: oidc-provider 永続化 db.exec(` CREATE TABLE IF NOT EXISTS oidc_models ( diff --git a/src/db/repositories/a2a.ts b/src/db/repositories/a2a.ts new file mode 100644 index 0000000..3e1dec3 --- /dev/null +++ b/src/db/repositories/a2a.ts @@ -0,0 +1,336 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// a2a repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; + +export interface A2aClientRow { + clientId: string; + name: string; + redirectUris: string[]; + secretHash: string | null; + tokenEndpointAuthMethod: string; + agentCardUrl: string | null; + issuer: string | null; + keyFingerprint: string | null; + status: string; + createdBy: string | null; + createdAt?: string; +} + + +export interface A2aDelegationRow { + id: string; + userId: string; + clientId: string; + grantId: string | null; + grantedSpaceIds: string[]; + grantedSkills: string[]; + audience: string | null; + expiresAt: string | null; + revokedAt: string | null; + createdAt?: string; +} + + + export function oidcUpsert(db: Database.Database, model: string, id: string, payload: object, opts: { expiresAt?: number | null; grantId?: string | null; userCode?: string | null; uid?: string | null } = {}): void { + db.prepare(` + INSERT INTO oidc_models (model, id, payload, grant_id, user_code, uid, expires_at, consumed_at) + VALUES (@model, @id, @payload, @grantId, @userCode, @uid, @expiresAt, NULL) + ON CONFLICT(model, id) DO UPDATE SET + payload = excluded.payload, + grant_id = excluded.grant_id, + user_code = excluded.user_code, + uid = excluded.uid, + expires_at = excluded.expires_at + `).run({ + model, id, + payload: JSON.stringify(payload), + grantId: opts.grantId ?? null, + userCode: opts.userCode ?? null, + uid: opts.uid ?? null, + expiresAt: opts.expiresAt ?? null, + }); + } + + + export function oidcFind(db: Database.Database, model: string, id: string): { payload: Record; consumedAt: number | null } | undefined { + const row = db.prepare( + 'SELECT payload, consumed_at FROM oidc_models WHERE model = ? AND id = ?', + ).get(model, id) as { payload: string; consumed_at: number | null } | undefined; + if (!row) return undefined; + return { payload: JSON.parse(row.payload), consumedAt: row.consumed_at }; + } + + + export function oidcFindByUid(db: Database.Database, uid: string): { payload: Record } | undefined { + const row = db.prepare( + "SELECT payload FROM oidc_models WHERE model = 'Session' AND uid = ?", + ).get(uid) as { payload: string } | undefined; + return row ? { payload: JSON.parse(row.payload) } : undefined; + } + + + export function oidcFindByUserCode(db: Database.Database, userCode: string): { payload: Record } | undefined { + const row = db.prepare( + 'SELECT payload FROM oidc_models WHERE user_code = ?', + ).get(userCode) as { payload: string } | undefined; + return row ? { payload: JSON.parse(row.payload) } : undefined; + } + + + export function oidcConsume(db: Database.Database, model: string, id: string, consumedAt: number): void { + db.prepare('UPDATE oidc_models SET consumed_at = ? WHERE model = ? AND id = ?') + .run(consumedAt, model, id); + } + + + export function oidcDestroy(db: Database.Database, model: string, id: string): void { + db.prepare('DELETE FROM oidc_models WHERE model = ? AND id = ?').run(model, id); + } + + + export function oidcRevokeByGrantId(db: Database.Database, grantId: string): void { + db.prepare('DELETE FROM oidc_models WHERE grant_id = ?').run(grantId); + } + + + export function createA2aClient(db: Database.Database, row: A2aClientRow): void { + db.prepare(` + INSERT INTO a2a_clients + (client_id, name, redirect_uris, secret_hash, token_endpoint_auth_method, + agent_card_url, issuer, key_fingerprint, status, created_by) + VALUES (@clientId, @name, @redirectUris, @secretHash, @tokenEndpointAuthMethod, + @agentCardUrl, @issuer, @keyFingerprint, @status, @createdBy) + `).run({ + ...row, + redirectUris: JSON.stringify(row.redirectUris), + }); + } + + + export function getA2aClient(db: Database.Database, clientId: string): A2aClientRow | undefined { + const r = db.prepare('SELECT * FROM a2a_clients WHERE client_id = ?').get(clientId) as any; + if (!r) return undefined; + return { + clientId: r.client_id, name: r.name, redirectUris: JSON.parse(r.redirect_uris), + secretHash: r.secret_hash, tokenEndpointAuthMethod: r.token_endpoint_auth_method, + agentCardUrl: r.agent_card_url, issuer: r.issuer, keyFingerprint: r.key_fingerprint, + status: r.status, createdBy: r.created_by, createdAt: r.created_at, + }; + } + + + export function listA2aClients(db: Database.Database): A2aClientRow[] { + const rows = db.prepare('SELECT * FROM a2a_clients ORDER BY created_at DESC').all() as any[]; + return rows.map(r => ({ + clientId: r.client_id, name: r.name, redirectUris: JSON.parse(r.redirect_uris), + secretHash: r.secret_hash, tokenEndpointAuthMethod: r.token_endpoint_auth_method, + agentCardUrl: r.agent_card_url, issuer: r.issuer, keyFingerprint: r.key_fingerprint, + status: r.status, createdBy: r.created_by, createdAt: r.created_at, + })); + } + + + export function setA2aClientStatus(db: Database.Database, clientId: string, status: 'active' | 'disabled'): void { + db.prepare('UPDATE a2a_clients SET status = ? WHERE client_id = ?').run(status, clientId); + } + + + export function createA2aDelegation(db: Database.Database, row: A2aDelegationRow): void { + db.prepare(` + INSERT INTO a2a_delegations + (id, user_id, client_id, grant_id, granted_space_ids, granted_skills, audience, expires_at, revoked_at) + VALUES (@id, @userId, @clientId, @grantId, @grantedSpaceIds, @grantedSkills, @audience, @expiresAt, @revokedAt) + `).run({ + id: row.id, userId: row.userId, clientId: row.clientId, grantId: row.grantId, + grantedSpaceIds: JSON.stringify(row.grantedSpaceIds), + grantedSkills: JSON.stringify(row.grantedSkills), + audience: row.audience, expiresAt: row.expiresAt, revokedAt: row.revokedAt, + }); + } + + + export function mapDelegation(r: any): A2aDelegationRow { + return { + id: r.id, userId: r.user_id, clientId: r.client_id, grantId: r.grant_id, + grantedSpaceIds: JSON.parse(r.granted_space_ids || '[]'), + grantedSkills: JSON.parse(r.granted_skills || '[]'), + audience: r.audience, expiresAt: r.expires_at, revokedAt: r.revoked_at, createdAt: r.created_at, + }; + } + + + export function getA2aDelegationByGrantId(db: Database.Database, grantId: string): A2aDelegationRow | undefined { + const r = db.prepare('SELECT * FROM a2a_delegations WHERE grant_id = ?').get(grantId); + return r ? mapDelegation(r) : undefined; + } + + + export function listA2aDelegationsForUser(db: Database.Database, userId: string): A2aDelegationRow[] { + return (db.prepare('SELECT * FROM a2a_delegations WHERE user_id = ? ORDER BY created_at DESC').all(userId) as any[]) + .map(r => mapDelegation(r)); + } + + + export function revokeA2aDelegation(db: Database.Database, id: string, revokedAtIso: string): void { + // revoked_at IS NULL ガード: 二重失効で元の失効時刻を上書きしない(監査の forensic 記録を守る) + db + .prepare('UPDATE a2a_delegations SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL') + .run(revokedAtIso, id); + } + + + export function getA2aDelegationById(db: Database.Database, id: string): A2aDelegationRow | undefined { + const r = db.prepare('SELECT * FROM a2a_delegations WHERE id = ?').get(id); + return r ? mapDelegation(r) : undefined; + } + + + export function listLiveA2aDelegationsForClient(db: Database.Database, clientId: string, nowIso: string): A2aDelegationRow[] { + const rows = db.prepare( + `SELECT * FROM a2a_delegations + WHERE client_id = ? AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + ORDER BY created_at DESC`, + ).all(clientId, nowIso); + return (rows as any[]).map((r) => mapDelegation(r)); + } + + + export function listNonTerminalA2aTasksByGrant(db: Database.Database, grantId: string, limit = 500): Array<{ id: string; jobId: string | null; payload: Record }> { + const rows = db.prepare( + `SELECT id, job_id, payload FROM a2a_tasks + WHERE grant_id = ? + AND (json_extract(payload, '$.status.state') NOT IN + ('completed','failed','canceled','rejected') + OR json_extract(payload, '$.status.state') IS NULL) + ORDER BY updated_at + LIMIT ?`, + ).all(grantId, limit); + return (rows as any[]).map((r) => ({ + id: r.id, + jobId: r.job_id ?? null, + payload: JSON.parse(r.payload ?? '{}'), + })); + } + + + export function countNonTerminalA2aTasksByGrant(db: Database.Database, grantId: string): number { + // Concurrency cap counts tasks that currently (or imminently) consume a job slot. + // 'input-required' is excluded ONLY while its linked job is genuinely parked + // (waiting_human) or gone — a parked task holds no live worker/LLM capacity, so it must + // not block new task creation. But if the MAESTRO-side human answers the ASK, the job + // resumes (waiting_human → running) while the a2a_tasks payload may still read + // 'input-required'; that resumed task DOES consume capacity and must be counted. + // Keying the exclusion on the live job status (via LEFT JOIN) instead of the frozen + // payload state closes a concurrency-cap bypass by ASK-park-then-resume. + // NOTE: listNonTerminalA2aTasksByGrant intentionally keeps the 4-state exclusion (no + // input-required) so the revocation cascade can still find and cancel parked tasks. + const row = db.prepare( + `SELECT COUNT(*) AS n FROM a2a_tasks t + LEFT JOIN jobs j ON j.id = t.job_id + WHERE t.grant_id = ? + AND (json_extract(t.payload, '$.status.state') NOT IN + ('completed','failed','canceled','rejected') + OR json_extract(t.payload, '$.status.state') IS NULL) + AND NOT (json_extract(t.payload, '$.status.state') = 'input-required' + AND COALESCE(j.status, 'waiting_human') = 'waiting_human')`, + ).get(grantId); + return (row as { n: number }).n; + } + + + export function cancelA2aLinkedJob(db: Database.Database, jobId: string): boolean { + const res = db.prepare( + `UPDATE jobs SET status = 'cancelled', updated_at = datetime('now') + WHERE id = ? AND status IN + ('queued','dispatching','running','waiting_human','waiting_subtasks','retry')`, + ).run(jobId); + return res.changes > 0; + } + + + export function listA2aDelegationsForUserWithClient(db: Database.Database, userId: string): Array { + const rows = db.prepare( + `SELECT d.*, c.name AS client_name, c.status AS client_status + FROM a2a_delegations d + LEFT JOIN a2a_clients c ON c.client_id = d.client_id + WHERE d.user_id = ? + ORDER BY d.created_at DESC`, + ).all(userId); + return (rows as any[]).map((r) => ({ + ...mapDelegation(r), + clientName: r.client_name ?? null, + clientStatus: r.client_status ?? null, + })); + } + + + export function listA2aDelegationsAllWithClient(db: Database.Database): Array< + A2aDelegationRow & { userId: string; clientName: string | null; clientStatus: string | null } + > { + const rows = db.prepare( + `SELECT d.*, c.name AS client_name, c.status AS client_status + FROM a2a_delegations d + LEFT JOIN a2a_clients c ON c.client_id = d.client_id + ORDER BY d.created_at DESC`, + ).all(); + return (rows as any[]).map((r) => ({ + ...mapDelegation(r), + clientName: r.client_name ?? null, + clientStatus: r.client_status ?? null, + })); + } + + + export function saveA2aTask(db: Database.Database, row: { + id: string; + contextId: string | null; + jobId: string | null; + localTaskId: number | null; + payload: object; + delegationId?: string; + grantId?: string; + actingUserId?: string; + }): void { + db.prepare(` + INSERT INTO a2a_tasks (id, context_id, job_id, local_task_id, payload, updated_at, delegation_id, grant_id, acting_user_id) + VALUES (@id, @contextId, @jobId, @localTaskId, @payload, datetime('now'), @delegationId, @grantId, @actingUserId) + ON CONFLICT(id) DO UPDATE SET + context_id = excluded.context_id, job_id = excluded.job_id, + local_task_id = excluded.local_task_id, payload = excluded.payload, updated_at = datetime('now'), + delegation_id = excluded.delegation_id, grant_id = excluded.grant_id, acting_user_id = excluded.acting_user_id + `).run({ + id: row.id, + contextId: row.contextId, + jobId: row.jobId, + localTaskId: row.localTaskId, + payload: JSON.stringify(row.payload), + delegationId: row.delegationId ?? null, + grantId: row.grantId ?? null, + actingUserId: row.actingUserId ?? null, + }); + } + + + export function loadA2aTask(db: Database.Database, id: string): { payload: Record } | undefined { + const r = db.prepare('SELECT payload FROM a2a_tasks WHERE id = ?').get(id) as { payload: string } | undefined; + return r ? { payload: JSON.parse(r.payload) } : undefined; + } + + + export function getA2aTaskByJobId(db: Database.Database, jobId: string): { id: string; payload: Record } | undefined { + const r = db.prepare('SELECT id, payload FROM a2a_tasks WHERE job_id = ?').get(jobId) as { id: string; payload: string } | undefined; + return r ? { id: r.id, payload: JSON.parse(r.payload) } : undefined; + } + + + export function listNonTerminalA2aTasks(db: Database.Database, limit = 500): Array<{ id: string; jobId: string | null; payload: Record }> { + const rows = db.prepare(` + SELECT id, job_id, payload FROM a2a_tasks + WHERE json_extract(payload, '$.status.state') NOT IN ('completed', 'failed', 'canceled', 'rejected') + OR json_extract(payload, '$.status.state') IS NULL + ORDER BY updated_at + LIMIT ? + `).all(limit) as Array<{ id: string; job_id: string | null; payload: string }>; + return rows.map(r => ({ id: r.id, jobId: r.job_id, payload: JSON.parse(r.payload) })); + } diff --git a/src/db/repositories/app-shares.ts b/src/db/repositories/app-shares.ts new file mode 100644 index 0000000..abaee83 --- /dev/null +++ b/src/db/repositories/app-shares.ts @@ -0,0 +1,69 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// app-shares repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + + // ─── 公開アプリ共有リンク(read-only) ─────────────────────────────── + // + // スペース内の 1 ワークスペースアプリ(apps/{appName}/)を、ログイン不要の + // 公開 URL で read-only 共有するためのトークン。失効後の再発行は新トークンを + // 作る(失効済みトークンは再利用しない)ため、未失効リンクが既にある場合のみ + // 再利用する。 + + /** (spaceId, appName) の未失効リンクがあれば再利用、無ければ新規発行して token を返す。 */ + export function createAppShareLink(db: Database.Database, spaceId: string, appName: string, createdBy: string | null): { token: string } { + const existing = db + .prepare( + `SELECT token FROM app_share_links + WHERE space_id = ? AND app_name = ? AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + ) + .get(spaceId, appName) as { token: string } | undefined; + if (existing) return { token: existing.token }; + const token = randomUUID(); + db + .prepare( + `INSERT INTO app_share_links (token, space_id, app_name, created_by) + VALUES (?, ?, ?, ?)`, + ) + .run(token, spaceId, appName, createdBy ?? null); + return { token }; + } + + + /** (spaceId, appName) の最新リンク(有効・無効問わず)を返す。無ければ null。 */ + export function getAppShareLink(db: Database.Database, spaceId: string, appName: string): { token: string; revokedAt: string | null } | null { + const row = db + .prepare( + `SELECT token, revoked_at FROM app_share_links + WHERE space_id = ? AND app_name = ? + ORDER BY created_at DESC LIMIT 1`, + ) + .get(spaceId, appName) as { token: string; revoked_at: string | null } | undefined; + if (!row) return null; + return { token: row.token, revokedAt: row.revoked_at ?? null }; + } + + + /** (spaceId, appName) の未失効リンクを全て失効させる。行が無ければ no-op。 */ + export function revokeAppShareLink(db: Database.Database, spaceId: string, appName: string): void { + db + .prepare( + `UPDATE app_share_links SET revoked_at = datetime('now') + WHERE space_id = ? AND app_name = ? AND revoked_at IS NULL`, + ) + .run(spaceId, appName); + } + + + /** トークンを (spaceId, appName) に解決する。失効済み・不正トークンは null。 */ + export function resolveAppShareToken(db: Database.Database, token: string): { spaceId: string; appName: string } | null { + const row = db + .prepare( + `SELECT space_id, app_name FROM app_share_links + WHERE token = ? AND revoked_at IS NULL`, + ) + .get(token) as { space_id: string; app_name: string } | undefined; + if (!row) return null; + return { spaceId: row.space_id, appName: row.app_name }; + } diff --git a/src/db/repositories/audit.ts b/src/db/repositories/audit.ts new file mode 100644 index 0000000..f03a94a --- /dev/null +++ b/src/db/repositories/audit.ts @@ -0,0 +1,9 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// audit repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; + + export async function addAuditLog(db: Database.Database, jobId: string | null, action: string, actor: string, detail: object): Promise { + db + .prepare('INSERT INTO audit_log (job_id, action, actor, detail) VALUES (?, ?, ?, ?)') + .run(jobId, action, actor, JSON.stringify(detail)); + } diff --git a/src/db/repositories/calendar.ts b/src/db/repositories/calendar.ts new file mode 100644 index 0000000..78d3f02 --- /dev/null +++ b/src/db/repositories/calendar.ts @@ -0,0 +1,430 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// calendar repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { buildVisibilityWhere } from '../../bridge/visibility.js'; +import { spaceColor } from '../../spaces/space-color.js'; +import * as spacesRepo from './spaces.js'; + +/** + * Allowed widget kinds. 'markdown' is the original Side Info Panel widget + * (PR #308). 'node-status' was added in Phase B (2026-05) and ignores + * markdown_content — it renders BackendStatusRegistry data live. The union + * intentionally lives here so the API, tools and UI all share a single + * source of truth. + */ +/** + * スペース・カレンダーの予定エントリ(passive。実行はしない)。 + * spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §データモデル + */ +export interface CalendarEvent { + id: number; + spaceId: string; + ownerId: string | null; + date: string; // 開始日 YYYY-MM-DD(ローカル日付) + endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日) + time: string | null; // 開始 HH:MM。null = 終日 + endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) + title: string; + description: string | null; + createdBy: 'user' | 'agent'; + sourceTaskId: number | null; + createdAt: string; + updatedAt: string; +} + + +export interface CalendarEventRow { + id: number; + space_id: string; + owner_id: string | null; + date: string; + end_date: string | null; + time: string | null; + end_time: string | null; + title: string; + description: string | null; + created_by: string; + source_task_id: number | null; + created_at: string; + updated_at: string; +} + + +/** + * [start, end](両端含む)のローカル暦日を 'YYYY-MM-DD' 配列で返す。 + * end < start は空配列。複数日イベントを各日に展開する集計で使う。 + */ +export function enumerateLocalDays(start: string, end: string): string[] { + if (end < start) return []; + const out: string[] = []; + const [ys, ms, ds] = start.split('-').map(Number); + const [ye, me, de] = end.split('-').map(Number); + let cur = Date.UTC(ys, ms - 1, ds); + const last = Date.UTC(ye, me - 1, de); + // 安全弁: 異常な範囲で無限ループしないよう上限を設ける(最大 ~2 年)。 + for (let i = 0; cur <= last && i < 800; i++) { + const d = new Date(cur); + out.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`); + cur += 86_400_000; + } + return out; +} + + +export function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent { + return { + id: row.id, + spaceId: row.space_id, + ownerId: row.owner_id ?? null, + date: row.date, + endDate: row.end_date ?? null, + time: row.time ?? null, + endTime: row.end_time ?? null, + title: row.title, + description: row.description ?? null, + createdBy: row.created_by === 'agent' ? 'agent' : 'user', + sourceTaskId: row.source_task_id ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + + +/** カレンダー月ビューの1日分の集計カウント。 */ +export interface CalendarDayCounts { + taskCount: number; + eventCount: number; +} + + +/** カレンダー日詳細のタスク行(軽量・最新 job 状態付き)。 */ +export interface CalendarDayTask { + id: number; + title: string; + state: string; + status: string | null; // 最新 job の status(無ければ null) + createdAt: string; +} + + +/** 横断カレンダーの凡例 1 行(スペースとその表示色)。 */ +export interface CrossCalendarSpace { + id: string; + name: string; + color: string; // brand_color、無ければ spaceColor(id) で決定論的に導出 +} + + +/** 横断カレンダー月ビュー。`days[date][spaceId]` = そのスペースのその日の集計。 */ +export interface CrossSpaceCalendarMonth { + days: Record>; + events: CalendarEvent[]; // 各イベントは spaceId を持つ(CalendarEvent.spaceId) + spaces: CrossCalendarSpace[]; +} + + + // ── スペース・カレンダー(予定)───────────────────────────────────── + // + // CRUD はスペース id でスコープ。可視性判定は呼び出し側(space-api が + // getSpace({viewer}) で先にゲートする)に委ねる。日付集計はローカル TZ + // オフセット(分)を SQLite の date(...,'±N minutes') 修飾子で適用する + // (Usage ダッシュボードの localDayOf と同じ「UTC instant をオフセット分 + // ずらして暦日に落とす」方式の SQL 版)。 + + /** ローカル TZ オフセット(分)を SQLite の date() 修飾子文字列に変換する。 */ + export function tzModifier(tzOffsetMin: number): string { + const n = Math.trunc(tzOffsetMin); + return `${n >= 0 ? '+' : '-'}${Math.abs(n)} minutes`; + } + + + export async function createCalendarEvent(db: Database.Database, params: { + spaceId: string; + ownerId?: string | null; + date: string; + endDate?: string | null; + time?: string | null; + endTime?: string | null; + title: string; + description?: string | null; + createdBy?: 'user' | 'agent'; + sourceTaskId?: number | null; + }): Promise { + // 終了日が開始日と同じ(または無効)なら単日として NULL に正規化する。 + const endDate = params.endDate && params.endDate > params.date ? params.endDate : null; + const time = params.time ?? null; + // 終了時刻は開始時刻があるときだけ意味を持つ。開始が終日なら end_time は捨てる。 + const endTime = time ? (params.endTime ?? null) : null; + const result = db + .prepare( + `INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + params.spaceId, + params.ownerId ?? null, + params.date, + endDate, + time, + endTime, + params.title, + params.description ?? null, + params.createdBy ?? 'user', + params.sourceTaskId ?? null, + ); + const row = db + .prepare(`SELECT * FROM calendar_events WHERE id = ?`) + .get(Number(result.lastInsertRowid)) as CalendarEventRow; + return rowToCalendarEvent(row); + } + + + export async function listCalendarEvents(db: Database.Database, spaceId: string, range?: { from?: string; to?: string }): Promise { + const conditions = ['space_id = ?']; + const args: unknown[] = [spaceId]; + // 複数日対応: イベント [date, COALESCE(end_date,date)] が [from, to] と + // 重なるものを返す(単日 end_date=NULL は date 自身)。 + if (range?.from) { + conditions.push('COALESCE(end_date, date) >= ?'); + args.push(range.from); + } + if (range?.to) { + conditions.push('date <= ?'); + args.push(range.to); + } + const rows = db + .prepare( + `SELECT * FROM calendar_events WHERE ${conditions.join(' AND ')} + ORDER BY date ASC, (time IS NULL) DESC, time ASC, id ASC` + ) + .all(...args) as CalendarEventRow[]; + return rows.map(rowToCalendarEvent); + } + + + export async function getCalendarEvent(db: Database.Database, eventId: number): Promise { + const row = db + .prepare(`SELECT * FROM calendar_events WHERE id = ?`) + .get(eventId) as CalendarEventRow | undefined; + return row ? rowToCalendarEvent(row) : null; + } + + + export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise { + const sets: string[] = []; + const args: unknown[] = []; + if (fields.date !== undefined) { + sets.push('date = ?'); + args.push(fields.date); + } + if (fields.endDate !== undefined) { + sets.push('end_date = ?'); + args.push(fields.endDate); + } + // time / end_time は連動。開始を終日(null)にしたら end_time も必ず null に落とす。 + if (fields.time !== undefined || fields.endTime !== undefined) { + const existing = await getCalendarEvent(db, eventId); + const newTime = fields.time !== undefined ? fields.time : (existing?.time ?? null); + let newEndTime = fields.endTime !== undefined ? fields.endTime : (existing?.endTime ?? null); + if (newTime == null) newEndTime = null; + if (fields.time !== undefined) { + sets.push('time = ?'); + args.push(newTime); + } + if (newEndTime !== (existing?.endTime ?? null)) { + sets.push('end_time = ?'); + args.push(newEndTime); + } + } + if (fields.title !== undefined) { + sets.push('title = ?'); + args.push(fields.title); + } + if (fields.description !== undefined) { + sets.push('description = ?'); + args.push(fields.description); + } + if (sets.length === 0) return getCalendarEvent(db, eventId); + sets.push(`updated_at = datetime('now')`); + args.push(eventId); + db.prepare(`UPDATE calendar_events SET ${sets.join(', ')} WHERE id = ?`).run(...args); + return getCalendarEvent(db, eventId); + } + + + export async function deleteCalendarEvent(db: Database.Database, eventId: number): Promise { + db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId); + } + + + /** + * 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。 + * タスクは local_tasks を space_id で絞り、created_at(UTC)をローカル TZ で + * 暦日にバケツ化した日付が [monthStart, monthEnd] に入るものを数える。 + * イベントは date 列がそのまま暦日(ローカル)なので素直に範囲で絞る。 + * ファイル数は重いので含めない(日詳細でのみ算出)。 + */ + /** + * SQL scope for "tasks belonging to this space's calendar". Normally just + * `space_id = ?`. A PERSONAL space also owns its owner's space-less tasks: + * personal-workspace tasks are stored with `space_id = NULL` (by design), so + * include `(space_id IS NULL AND owner_id = ?)` when a personalOwnerId is + * given. Mirrors worker.ts resolveToolSpaceId (NULL+owner → personal space). + */ + export function calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } { + const col = alias ? `${alias}.` : ''; + if (personalOwnerId) { + return { + clause: `(${col}space_id = ? OR (${col}space_id IS NULL AND ${col}owner_id = ?))`, + params: [spaceId, personalOwnerId], + }; + } + return { clause: `${col}space_id = ?`, params: [spaceId] }; + } + + + export async function getSpaceCalendarMonth(db: Database.Database, spaceId: string, opts: { monthStart: string; monthEnd: string; tzOffsetMin: number; personalOwnerId?: string | null }): Promise<{ days: Record; events: CalendarEvent[] }> { + const mod = tzModifier(opts.tzOffsetMin); + const days: Record = {}; + const ensure = (d: string): CalendarDayCounts => { + if (!days[d]) days[d] = { taskCount: 0, eventCount: 0 }; + return days[d]!; + }; + + const scope = calendarTaskScope(spaceId, opts.personalOwnerId); + const taskRows = db + .prepare( + `SELECT date(created_at, ?) AS local_day, COUNT(*) AS c + FROM local_tasks + WHERE ${scope.clause} AND date(created_at, ?) BETWEEN ? AND ? + GROUP BY local_day` + ) + .all(mod, ...scope.params, mod, opts.monthStart, opts.monthEnd) as Array<{ local_day: string; c: number }>; + for (const r of taskRows) ensure(r.local_day).taskCount = r.c; + + const events = await listCalendarEvents(db, spaceId, { from: opts.monthStart, to: opts.monthEnd }); + // 複数日イベントは、またがる各日(当月内にクリップ)に 1 件ずつ計上する。 + for (const ev of events) { + const start = ev.date < opts.monthStart ? opts.monthStart : ev.date; + const end = (ev.endDate ?? ev.date) > opts.monthEnd ? opts.monthEnd : (ev.endDate ?? ev.date); + for (const d of enumerateLocalDays(start, end)) ensure(d).eventCount += 1; + } + return { days, events }; + } + + + /** + * 日詳細。tasks = その日(ローカル暦日)に作成された local_tasks(最新 job + * 状態付き)、events = その date の calendar_events。ファイル列挙は API 層 + * (fs スキャン)で行う。 + */ + export async function getSpaceCalendarDay(db: Database.Database, spaceId: string, date: string, tzOffsetMin: number, personalOwnerId?: string | null): Promise<{ tasks: CalendarDayTask[]; events: CalendarEvent[] }> { + const mod = tzModifier(tzOffsetMin); + const scope = calendarTaskScope(spaceId, personalOwnerId, 'lt'); + const taskRows = db + .prepare( + `SELECT lt.id AS id, lt.title AS title, lt.state AS state, lt.created_at AS created_at, + (SELECT j.status FROM jobs j + WHERE j.repo = 'local/task-' || lt.id + ORDER BY j.created_at DESC LIMIT 1) AS status + FROM local_tasks lt + WHERE ${scope.clause} AND date(lt.created_at, ?) = ? + ORDER BY lt.created_at ASC, lt.id ASC` + ) + .all(...scope.params, mod, date) as Array<{ + id: number; title: string; state: string; created_at: string; status: string | null; + }>; + const tasks: CalendarDayTask[] = taskRows.map((r) => ({ + id: r.id, + title: r.title, + state: r.state, + status: r.status ?? null, + createdAt: r.created_at, + })); + const events = await listCalendarEvents(db, spaceId, { from: date, to: date }); + return { tasks, events }; + } + + + /** + * 横断(全スペース)月ビュー。viewer が見られるスペースだけを対象に、 + * 日 × スペースの {taskCount, eventCount} を集計する。可視性は spaces + * 一覧と同じ `buildVisibilityWhere`(listSpaces 経由)で絞るので、 + * 見えないスペースのカウントもイベントも一切返さない。 + * + * クエリ本数を抑えるため、対象 space_id を IN (...) でまとめて 2 本に集約する: + * 1) local_tasks を space_id + ローカル暦日で GROUP BY + * 2) calendar_events を space_id + date で GROUP BY + * さらに当月のイベント本体を 1 本でまとめて取得する(各 CalendarEvent は + * spaceId を持つので UI 側でスペース別に振り分けられる)。 + */ + export async function getCrossSpaceCalendarMonth(db: Database.Database, viewer: Express.User, opts: { monthStart: string; monthEnd: string; tzOffsetMin: number }): Promise { + // 可視スペースの列挙は spaces 一覧とまったく同じ可視性判定を流用する。 + const visibleSpaces = await spacesRepo.listSpaces(db, { viewer }); + const spaces: CrossCalendarSpace[] = visibleSpaces.map((s) => ({ + id: s.id, + name: s.title, + color: s.brandColor ?? spaceColor(s.id), + })); + + const days: Record> = {}; + if (spaces.length === 0) { + return { days, events: [], spaces }; + } + + const ids = spaces.map((s) => s.id); + const placeholders = ids.map(() => '?').join(','); + const mod = tzModifier(opts.tzOffsetMin); + const ensure = (date: string, spaceId: string): CalendarDayCounts => { + const byDay = (days[date] ??= {}); + return (byDay[spaceId] ??= { taskCount: 0, eventCount: 0 }); + }; + + const taskRows = db + .prepare( + `SELECT space_id AS sid, date(created_at, ?) AS local_day, COUNT(*) AS c + FROM local_tasks + WHERE space_id IN (${placeholders}) AND date(created_at, ?) BETWEEN ? AND ? + GROUP BY sid, local_day` + ) + .all(mod, ...ids, mod, opts.monthStart, opts.monthEnd) as Array<{ + sid: string; local_day: string; c: number; + }>; + for (const r of taskRows) ensure(r.local_day, r.sid).taskCount = r.c; + + // 個人スペースのタスクは space_id が NULL(owner_id で紐付く)ため、上の + // `space_id IN (...)` では拾えず全体カレンダーに反映されなかった。per-space カレンダーの + // calendarTaskScope と同じ owner フォールバックで、`space_id IS NULL AND owner_id=個人スペース + // 所有者` のタスクを個人スペース列に計上する。 + for (const ps of visibleSpaces) { + if (ps.kind !== 'personal' || !ps.ownerId) continue; + const personalRows = db + .prepare( + `SELECT date(created_at, ?) AS local_day, COUNT(*) AS c + FROM local_tasks + WHERE space_id IS NULL AND owner_id = ? AND date(created_at, ?) BETWEEN ? AND ? + GROUP BY local_day` + ) + .all(mod, ps.ownerId, mod, opts.monthStart, opts.monthEnd) as Array<{ local_day: string; c: number }>; + for (const r of personalRows) ensure(r.local_day, ps.id).taskCount += r.c; + } + + // 複数日対応: 当月に重なるイベントを 1 本でまとめて取得し、各日(当月内に + // クリップ)に 1 件ずつ計上する。終了日が当月外の単日 end_date=NULL は date 自身。 + const eventRows = db + .prepare( + `SELECT * FROM calendar_events + WHERE space_id IN (${placeholders}) + AND COALESCE(end_date, date) >= ? AND date <= ? + ORDER BY date ASC, (time IS NULL) DESC, time ASC, id ASC` + ) + .all(...ids, opts.monthStart, opts.monthEnd) as CalendarEventRow[]; + const events = eventRows.map(rowToCalendarEvent); + for (const ev of events) { + const start = ev.date < opts.monthStart ? opts.monthStart : ev.date; + const end = (ev.endDate ?? ev.date) > opts.monthEnd ? opts.monthEnd : (ev.endDate ?? ev.date); + for (const d of enumerateLocalDays(start, end)) ensure(d, ev.spaceId).eventCount += 1; + } + + return { days, events, spaces }; + } diff --git a/src/db/repositories/gateway.ts b/src/db/repositories/gateway.ts new file mode 100644 index 0000000..90174c4 --- /dev/null +++ b/src/db/repositories/gateway.ts @@ -0,0 +1,487 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// gateway repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + +// ── AAO Gateway Phase 2a: virtual keys ───────────────────────────────── +// Stored shape returned by all gateway-key repository methods. The raw +// bearer is NEVER persisted or returned — only `keyHash` (sha256) lives +// in the DB, and `keyPrefix` is the human-readable head used in admin UI +// lists (`sk-aao-XXXXXX`). The raw key surfaces exactly once, from the +// admin API on issue / rotate; see src/bridge/admin-gateway-api.ts. + +export type GatewayVirtualKeySource = 'admin' | 'config-import'; + + +export interface GatewayVirtualKey { + id: string; + keyHash: string; + keyPrefix: string; + team: string; + /** Null = no per-key allowlist (any backend.model is accepted). */ + allowedModels: string[] | null; + source: GatewayVirtualKeySource; + createdAt: string; + createdBy: string | null; + /** ISO timestamp if revoked; null while active. */ + revokedAt: string | null; + revokedBy: string | null; + lastUsedAt: string | null; + /** + * Phase 2b: monthly tokens budget. NULL = unlimited. When set, the + * gateway rejects requests with 402 once the current UTC month + * `tokens_in + tokens_out` reaches this number (post-hoc enforcement — + * the offending request that pushes the counter over the limit still + * completes; the next one is rejected). + */ + tokensBudget: number | null; + /** + * Phase 2b: per-key requests-per-minute cap. NULL = unlimited. Enforced + * as a sliding 60-second window in-process; multi-instance setups are + * intentionally NOT synchronized (Phase 3 if needed). + */ + rateLimitRpm: number | null; +} + + +export interface GatewayVirtualKeyRow { + id: string; + key_hash: string; + key_prefix: string; + team: string; + allowed_models: string | null; + source: string; + created_at: string; + created_by: string | null; + revoked_at: string | null; + revoked_by: string | null; + last_used_at: string | null; + tokens_budget: number | null; + rate_limit_rpm: number | null; +} + + +// Phase 2b: monthly usage counter per virtual key. +export interface GatewayKeyUsage { + keyId: string; + /** UTC month bucket as 'YYYY-MM' — see src/gateway/period.ts. */ + periodStart: string; + tokensIn: number; + tokensOut: number; + requests: number; + lastUpdatedAt: string; +} + + +export interface GatewayKeyUsageRow { + key_id: string; + period_start: string; + tokens_in: number; + tokens_out: number; + requests: number; + last_updated_at: string; +} + + +export function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage { + return { + keyId: row.key_id, + periodStart: row.period_start, + tokensIn: row.tokens_in, + tokensOut: row.tokens_out, + requests: row.requests, + lastUpdatedAt: row.last_updated_at, + }; +} + + +/** + * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either + * a positive integer or null. Anything else (undefined, null, 0, + * negative, NaN, non-number) collapses to null = "no limit" so callers + * can't accidentally persist a value that would silently block all + * traffic. + */ +export function normalizeOptionalPositiveInt(v: unknown): number | null { + if (v === undefined || v === null) return null; + if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return null; + return Math.floor(v); +} + + +export function rowToGatewayVirtualKey(row: GatewayVirtualKeyRow): GatewayVirtualKey { + let allowedModels: string[] | null = null; + if (row.allowed_models !== null && row.allowed_models !== '') { + try { + const parsed: unknown = JSON.parse(row.allowed_models); + if (Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) { + allowedModels = parsed; + } + } catch { + // Corrupt JSON: treat as "no allowlist" (safer than fail-open + // because routing has its own backend.model gate; this is just + // the per-key filter on top). + allowedModels = null; + } + } + const source: GatewayVirtualKeySource = + row.source === 'config-import' ? 'config-import' : 'admin'; + // tokens_budget / rate_limit_rpm may legitimately arrive as null (no + // limit). Coerce non-positive integers to null defensively because the + // gateway middleware treats null as "unlimited" — a corrupt `0` would + // otherwise silently block every request. + const tokensBudget = + typeof row.tokens_budget === 'number' && Number.isFinite(row.tokens_budget) && row.tokens_budget > 0 + ? Math.floor(row.tokens_budget) + : null; + const rateLimitRpm = + typeof row.rate_limit_rpm === 'number' && Number.isFinite(row.rate_limit_rpm) && row.rate_limit_rpm > 0 + ? Math.floor(row.rate_limit_rpm) + : null; + return { + id: row.id, + keyHash: row.key_hash, + keyPrefix: row.key_prefix, + team: row.team, + allowedModels, + source, + createdAt: row.created_at, + createdBy: row.created_by, + revokedAt: row.revoked_at, + revokedBy: row.revoked_by, + lastUsedAt: row.last_used_at, + tokensBudget, + rateLimitRpm, + }; +} + + + // ── AAO Gateway Phase 2a: virtual keys ─────────────────────────────── + // + // The gateway auth middleware reads `findGatewayVirtualKeyByHash` on + // every request, so it MUST stay an indexed point lookup. The partial + // index `idx_gateway_keys_hash_active` covers that path. Admin-side + // methods (list/get/revoke/rotate/delete) are not hot. + + /** + * Insert a new virtual key row. Throws on UNIQUE(key_hash) violation — + * callers must hash the raw key first (via src/gateway/key-format.ts) + * and pass the hash here. The raw key is never accepted by the + * Repository on purpose: there is no path that could log it. + * + * `allowedModels` is JSON-encoded when present and stored as NULL when + * omitted — distinct from `[]` which means "lock to zero models". + */ + export function createGatewayVirtualKey(db: Database.Database, params: { + id?: string; + keyHash: string; + keyPrefix: string; + team: string; + allowedModels?: string[] | null; + source?: GatewayVirtualKeySource; + createdBy?: string | null; + createdAt?: string; + /** Phase 2b: optional monthly tokens budget. null/undefined = unlimited. */ + tokensBudget?: number | null; + /** Phase 2b: optional requests-per-minute cap. null/undefined = unlimited. */ + rateLimitRpm?: number | null; + }): GatewayVirtualKey { + const id = params.id ?? randomUUID(); + const allowedJson = + params.allowedModels === null || params.allowedModels === undefined + ? null + : JSON.stringify(params.allowedModels); + const source: GatewayVirtualKeySource = params.source ?? 'admin'; + const createdAt = params.createdAt ?? new Date().toISOString(); + const tokensBudget = normalizeOptionalPositiveInt(params.tokensBudget); + const rateLimitRpm = normalizeOptionalPositiveInt(params.rateLimitRpm); + db + .prepare( + `INSERT INTO gateway_virtual_keys + (id, key_hash, key_prefix, team, allowed_models, source, created_at, created_by, tokens_budget, rate_limit_rpm) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + params.keyHash, + params.keyPrefix, + params.team, + allowedJson, + source, + createdAt, + params.createdBy ?? null, + tokensBudget, + rateLimitRpm, + ); + const row = db + .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) + .get(id) as GatewayVirtualKeyRow; + return rowToGatewayVirtualKey(row); + } + + + /** + * Phase 2b: partial update of a virtual key's policy fields. The + * bearer hash, team, source, and creation metadata are immutable here + * (use rotate to change the bearer). Each field is opt-in — undefined + * means "leave alone"; explicit null clears the limit (= unlimited). + * + * Returns the refreshed row. Throws when the id doesn't exist (caller + * is expected to 404 before calling). + */ + export function updateGatewayVirtualKey(db: Database.Database, id: string, patch: { + /** + * Phase 3a follow-up: team is now patchable so the config-migration + * importer can propagate a YAML-side team rename to the DB. Admin + * PATCH never sends this field (the team is intentionally immutable + * via the public API to avoid an admin accidentally rewriting the + * owner of a key); the only caller is importConfigKeysToDb. + */ + team?: string; + tokensBudget?: number | null; + rateLimitRpm?: number | null; + allowedModels?: string[] | null; + }): GatewayVirtualKey { + const sets: string[] = []; + const args: unknown[] = []; + if (Object.prototype.hasOwnProperty.call(patch, 'team')) { + sets.push('team = ?'); + args.push(patch.team); + } + if (Object.prototype.hasOwnProperty.call(patch, 'tokensBudget')) { + sets.push('tokens_budget = ?'); + args.push(normalizeOptionalPositiveInt(patch.tokensBudget)); + } + if (Object.prototype.hasOwnProperty.call(patch, 'rateLimitRpm')) { + sets.push('rate_limit_rpm = ?'); + args.push(normalizeOptionalPositiveInt(patch.rateLimitRpm)); + } + if (Object.prototype.hasOwnProperty.call(patch, 'allowedModels')) { + sets.push('allowed_models = ?'); + args.push( + patch.allowedModels === null || patch.allowedModels === undefined + ? null + : JSON.stringify(patch.allowedModels), + ); + } + if (sets.length > 0) { + args.push(id); + db + .prepare(`UPDATE gateway_virtual_keys SET ${sets.join(', ')} WHERE id = ?`) + .run(...args); + } + const refreshed = findGatewayVirtualKeyById(db, id); + if (!refreshed) { + throw new Error(`updateGatewayVirtualKey: id not found (${id})`); + } + return refreshed; + } + + + /** + * Auth hot path: look up an active (non-revoked) key by SHA-256 hash. + * The partial index covers this query so the planner uses it directly. + * Returns null on miss; never throws. + */ + export function findGatewayVirtualKeyByHash(db: Database.Database, keyHash: string): GatewayVirtualKey | null { + const row = db + .prepare( + `SELECT * FROM gateway_virtual_keys WHERE key_hash = ? AND revoked_at IS NULL`, + ) + .get(keyHash) as GatewayVirtualKeyRow | undefined; + return row ? rowToGatewayVirtualKey(row) : null; + } + + + /** + * Admin lookup by row id. Includes revoked keys (the admin list/detail + * view shows them so an admin can audit a recent revoke). + */ + export function findGatewayVirtualKeyById(db: Database.Database, id: string): GatewayVirtualKey | null { + const row = db + .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) + .get(id) as GatewayVirtualKeyRow | undefined; + return row ? rowToGatewayVirtualKey(row) : null; + } + + + /** + * Admin list. `activeOnly` filters out revoked rows; `team` narrows by + * team string (exact match). Ordering is `created_at DESC, id DESC` so + * the freshest issuance is first regardless of system clock skew. + */ + export function listGatewayVirtualKeys(db: Database.Database, opts?: { team?: string; activeOnly?: boolean }): GatewayVirtualKey[] { + const where: string[] = []; + const args: unknown[] = []; + if (opts?.team !== undefined) { + where.push('team = ?'); + args.push(opts.team); + } + if (opts?.activeOnly) { + where.push('revoked_at IS NULL'); + } + const sql = + `SELECT * FROM gateway_virtual_keys` + + (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') + + ` ORDER BY created_at DESC, id DESC`; + const rows = db.prepare(sql).all(...args) as GatewayVirtualKeyRow[]; + return rows.map(rowToGatewayVirtualKey); + } + + + /** + * Mark a key as revoked. Returns true if the row was active (so the + * caller can return a clean 200) and false if it was already revoked + * or doesn't exist (so the caller can return 404 / 409). Idempotent + * second calls return false. + */ + export function revokeGatewayVirtualKey(db: Database.Database, id: string, revokedBy: string, at?: string): boolean { + const ts = at ?? new Date().toISOString(); + const info = db + .prepare( + `UPDATE gateway_virtual_keys + SET revoked_at = ?, revoked_by = ? + WHERE id = ? AND revoked_at IS NULL`, + ) + .run(ts, revokedBy, id); + return info.changes > 0; + } + + + /** + * Hard delete. The admin API guards `source='config-import'` and + * returns 400 before calling this — but the Repository itself doesn't + * enforce that (tests need to be able to clean up). Returns true if a + * row was deleted. + */ + /** + * Hard-delete a virtual key row. + * + * Defense-in-depth: refuses to delete rows with `source='config-import'` + * by throwing. The admin REST API also rejects this case (returning a + * 400 with a human-readable message), but a future internal caller + * could easily forget — and a hard delete of a config-import row + * would simply be replayed on the next gateway boot when + * importConfigKeysToDb re-imports the entry from config.yaml. That + * recreates the row with a different id, which silently breaks any + * audit history that referenced the previous id and is generally + * confusing operator behavior. Force callers to use `revoke` (soft + * delete) or to remove the entry from config.yaml first. + * + * Returns true when a row was deleted, false when the id didn't + * exist. Throws when the row exists but is config-import. + */ + export function deleteGatewayVirtualKey(db: Database.Database, id: string): boolean { + const row = findGatewayVirtualKeyById(db, id); + if (!row) return false; + if (row.source === 'config-import') { + throw new Error( + `cannot delete config-import virtual key (id=${id}); ` + + "remove the entry from config.yaml's gateway.virtual_keys instead, " + + 'or use revoke for a soft delete', + ); + } + const info = db + .prepare(`DELETE FROM gateway_virtual_keys WHERE id = ?`) + .run(id); + return info.changes > 0; + } + + + /** + * Bump `last_used_at` for an active key. Called from the gateway auth + * middleware on successful match. Per-request volume can be high, so + * callers typically dedup with a 30-second in-memory bucket (see + * src/gateway/auth.ts) before touching the DB. Best-effort: failures + * are swallowed by the caller so a temporary write-lock contention + * never blocks auth. + */ + export function touchGatewayVirtualKeyLastUsed(db: Database.Database, id: string, at?: string): void { + const ts = at ?? new Date().toISOString(); + db + .prepare(`UPDATE gateway_virtual_keys SET last_used_at = ? WHERE id = ?`) + .run(ts, id); + } + + + // ── AAO Gateway Phase 2b: usage tracking ───────────────────────────── + // + // Read path is on the budget enforcement hot loop, so it stays a + // single point lookup over the composite PRIMARY KEY. Write path is + // an UPSERT (`ON CONFLICT … DO UPDATE`) so the gateway can fire-and- + // forget after every chat completion without a pre-read. + + /** + * Point-lookup over the (key_id, period_start) PRIMARY KEY. Returns + * null when there's no row yet (= "no usage in this period"), which + * the caller treats as zero counters. + */ + export function getGatewayKeyUsage(db: Database.Database, keyId: string, periodStart: string): GatewayKeyUsage | null { + const row = db + .prepare( + `SELECT * FROM gateway_key_usage WHERE key_id = ? AND period_start = ?`, + ) + .get(keyId, periodStart) as GatewayKeyUsageRow | undefined; + return row ? rowToGatewayKeyUsage(row) : null; + } + + + /** + * UPSERT: bump the per-(key, period) counters by the supplied deltas. + * All three deltas are clamped at zero so a buggy caller can never + * decrement a counter. `last_updated_at` always reflects the call + * time (or the explicit `at` override) so a downstream sweeper can + * tell when activity stopped. + * + * Called from two places on the gateway hot path: + * 1. stream-proxy's finally block (token deltas from upstream usage) + * 2. rate-limiter's 30-second batch flush (request count only) + * + * The second caller passes `tokensIn=0 tokensOut=0` so the UPSERT + * still creates a row even when no token usage was extracted. + */ + export function incrementGatewayKeyUsage(db: Database.Database, params: { + keyId: string; + period: string; + tokensIn?: number; + tokensOut?: number; + requests?: number; + at?: string; + }): void { + const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); + const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); + const reqs = Math.max(0, Math.floor(params.requests ?? 0)); + const ts = params.at ?? new Date().toISOString(); + db + .prepare( + `INSERT INTO gateway_key_usage + (key_id, period_start, tokens_in, tokens_out, requests, last_updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (key_id, period_start) DO UPDATE SET + tokens_in = tokens_in + excluded.tokens_in, + tokens_out = tokens_out + excluded.tokens_out, + requests = requests + excluded.requests, + last_updated_at = excluded.last_updated_at`, + ) + .run(params.keyId, params.period, tIn, tOut, reqs, ts); + } + + + /** + * Admin view: history of usage rows for a key, freshest period first. + * Default limit 12 covers a full year of monthly buckets — enough for + * the in-UI bar chart and for `GET /:id/usage` to embed history without + * a follow-up call. + */ + export function listGatewayKeyUsagesByKey(db: Database.Database, keyId: string, opts?: { limit?: number }): GatewayKeyUsage[] { + const limit = Math.max(1, Math.min(120, Math.floor(opts?.limit ?? 12))); + const rows = db + .prepare( + `SELECT * FROM gateway_key_usage + WHERE key_id = ? + ORDER BY period_start DESC + LIMIT ?`, + ) + .all(keyId, limit) as GatewayKeyUsageRow[]; + return rows.map(rowToGatewayKeyUsage); + } diff --git a/src/db/repositories/jobs.ts b/src/db/repositories/jobs.ts new file mode 100644 index 0000000..5790b21 --- /dev/null +++ b/src/db/repositories/jobs.ts @@ -0,0 +1,886 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// jobs repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; +import { logger } from '../../logger.js'; +import { buildVisibilityWhere } from '../../bridge/visibility.js'; +import { utc, localTaskRepoName } from './shared.js'; + +export type JobStatus = + | 'queued' + | 'dispatching' + | 'running' + | 'succeeded' + | 'failed' + | 'retry' + | 'cancelled' + | 'waiting_human' + | 'waiting_subtasks'; + + +export type JobRole = 'auto' | 'fast' | 'quality' | 'reflection'; + +/** @deprecated Use JobRole instead */ +export type JobProfile = JobRole; + +/** @deprecated Removed — taskClass is no longer a separate concept */ +export type TaskClass = 'auto' | 'low_level' | 'high_level'; + + +export interface Job { + id: string; + repo: string; + issueNumber: number; + prNumber: number | null; + status: JobStatus; + pieceName: string; + currentMovement: string | null; + currentActivity: string | null; + instruction: string; + branchName: string | null; + worktreePath: string | null; + attempt: number; + maxAttempts: number; + nextRetryAt: string | null; + errorSummary: string | null; + abortReason: string | null; + resumeMovement: string | null; + waitReason: string | null; + askCount: number; + workerId: string | null; + /** + * Physical backend id (e.g. LiteLLM deployment name) that handled + * this job's LLM calls when running through a proxy worker. Set on + * the first LLM call and never overwritten — sticky-backend policy + * per design Open Question #3. NULL for direct workers and for jobs + * that haven't issued any proxied LLM call yet. + */ + lastBackendId: string | null; + parentJobId: string | null; + continuedFromJobId: string | null; + subtaskDepth: number; + requiredRole: JobRole; + /** @deprecated Use requiredRole */ + requiredProfile: JobRole; + ownerId: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + contextPromptTokens: number | null; + contextLimitTokens: number | null; + contextUpdatedAt: string | null; + browserSessionProfileId?: number | null; + taskKind: 'agent' | 'reflection'; + payload: string | null; + /** 所属スペース。親タスク/親ジョブから継承される(Task 5 で設定)。null は個人スペース。 */ + spaceId: string | null; + /** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。サブタスクが親から継承する。 */ + runtimeDir: string | null; + createdAt: string; + updatedAt: string; +} + + +export interface CreateJobParams { + repo: string; + issueNumber: number; + instruction: string; + pieceName?: string; + maxAttempts?: number; + resumeMovement?: string | null; + askCount?: number; + role?: JobRole; + /** @deprecated Use role instead */ + profile?: JobRole; + parentJobId?: string | null; + continuedFromJobId?: string | null; + subtaskDepth?: number; + ownerId?: string | null; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + browserSessionProfileId?: number | null; + taskKind?: 'agent' | 'reflection'; + payload?: string; + /** 所属スペース(spec §5.7 親→子継承)。NULL は owner 個人スペースに解決。 */ + spaceId?: string | null; + /** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */ + runtimeDir?: string | null; +} + + +export interface SubtaskInfo { + id: string; + issueNumber: number; + status: JobStatus; + instruction: string; + worktreePath: string | null; + createdAt: string; + updatedAt: string; + children?: SubtaskInfo[]; + childCount?: number; + childCompleted?: number; +} + + +export interface JobRow { + id: string; + repo: string; + issue_number: number; + pr_number: number | null; + status: string; + piece_name: string; + current_movement: string | null; + current_activity: string | null; + instruction: string; + branch_name: string | null; + worktree_path: string | null; + attempt: number; + max_attempts: number; + next_retry_at: string | null; + error_summary: string | null; + abort_reason: string | null; + resume_movement: string | null; + wait_reason: string | null; + ask_count: number; + worker_id: string | null; + last_backend_id: string | null; + parent_job_id: string | null; + continued_from_job_id: string | null; + subtask_depth: number; + required_profile: string; + task_class: string; + owner_id: string | null; + visibility: string | null; + visibility_scope_org_id: string | null; + context_prompt_tokens: number | null; + context_limit_tokens: number | null; + context_updated_at: string | null; + browser_session_profile_id: number | null; + task_kind: string; + payload: string | null; + space_id: string | null; + runtime_dir: string | null; + created_at: string; + updated_at: string; +} + + +export function isJobRole(value: string): value is JobRole { + return value === 'auto' || value === 'fast' || value === 'quality' || value === 'reflection'; +} + + +export function normalizeJobRole(value: string | undefined): JobRole { + return value && isJobRole(value) ? value : 'auto'; +} + + +export function deriveJobRole(instruction: string, explicitRole?: JobRole): JobRole { + if (explicitRole) return explicitRole; + const match = /ui_profile:\s*(auto|fast|quality)/i.exec(instruction); + return normalizeJobRole(match?.[1]?.toLowerCase()); +} + + +export function rowToJob(row: JobRow): Job { + return { + id: row.id, + repo: row.repo, + issueNumber: row.issue_number, + prNumber: row.pr_number, + status: row.status as JobStatus, + pieceName: row.piece_name, + currentMovement: row.current_movement, + currentActivity: row.current_activity, + instruction: row.instruction, + branchName: row.branch_name, + worktreePath: row.worktree_path, + attempt: row.attempt, + maxAttempts: row.max_attempts, + nextRetryAt: utc(row.next_retry_at), + errorSummary: row.error_summary, + abortReason: row.abort_reason ?? null, + resumeMovement: row.resume_movement, + waitReason: row.wait_reason ?? null, + askCount: row.ask_count, + workerId: row.worker_id, + lastBackendId: row.last_backend_id ?? null, + parentJobId: row.parent_job_id, + continuedFromJobId: row.continued_from_job_id ?? null, + subtaskDepth: row.subtask_depth ?? 0, + requiredRole: normalizeJobRole(row.required_profile), + requiredProfile: normalizeJobRole(row.required_profile), + ownerId: row.owner_id ?? null, + visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'), + visibilityScopeOrgId: row.visibility_scope_org_id ?? null, + contextPromptTokens: row.context_prompt_tokens, + contextLimitTokens: row.context_limit_tokens, + contextUpdatedAt: row.context_updated_at ? utc(row.context_updated_at) : null, + browserSessionProfileId: row.browser_session_profile_id ?? null, + taskKind: row.task_kind === 'reflection' ? 'reflection' : 'agent', + payload: row.payload, + spaceId: row.space_id ?? null, + runtimeDir: row.runtime_dir ?? null, + createdAt: utc(row.created_at), + updatedAt: utc(row.updated_at), + }; +} + + // Non-terminal job states. A task with a job in any of these already has work + // pending/running, so a new user comment should be appended to it rather than + // spawning a second job. waiting_human is excluded on purpose: a comment there + // is the answer that resumes via a fresh job. + const PENDING_JOB_STATES = ['queued', 'dispatching', 'running', 'waiting_subtasks', 'retry'] as const; + + + export function insertJobSync(db: Database.Database, params: CreateJobParams): Job { + const id = randomUUID(); + const now = new Date().toISOString(); + const pieceName = params.pieceName ?? 'chat'; + const maxAttempts = params.maxAttempts ?? 3; + const resumeMovement = params.resumeMovement ?? null; + const askCount = params.askCount ?? 0; + const requiredRole = deriveJobRole(params.instruction, params.role ?? params.profile); + + db + .prepare( + `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at) + VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)` + ) + .run({ + id, + repo: params.repo, + issueNumber: params.issueNumber, + pieceName, + instruction: params.instruction, + maxAttempts, + resumeMovement, + askCount, + requiredRole, + parentJobId: params.parentJobId ?? null, + continuedFromJobId: params.continuedFromJobId ?? null, + subtaskDepth: params.subtaskDepth ?? 0, + ownerId: params.ownerId ?? null, + visibility: params.visibility ?? 'private', + visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, + browserSessionProfileId: params.browserSessionProfileId ?? null, + taskKind: params.taskKind ?? 'agent', + payload: params.payload ?? null, + spaceId: params.spaceId ?? null, + runtimeDir: params.runtimeDir ?? null, + now, + }); + + const job = getJobSync(db, id); + if (!job) throw new Error(`createJob: failed to retrieve created job ${id}`); + return job; + } + + + export async function createJob(db: Database.Database, params: CreateJobParams): Promise { + return insertJobSync(db, params); + } + + + /** + * Atomically reuse-or-create. If a non-terminal job already exists for the + * issue, return it (created=false) without inserting; otherwise insert a new + * queued job (created=true). better-sqlite3 transactions run synchronously, so + * the check+insert can't interleave with a concurrent request — this closes + * the TOCTOU race where two near-simultaneous comments each spawned a job, + * leaving the newest (queued) duplicate as the task's latestJob while an older + * job actually ran (status stuck on "Inbox"). + */ + export function createJobIfNoPending(db: Database.Database, params: CreateJobParams, opts?: { blockOnToolRequestPause?: boolean }): { job: Job; created: boolean; blockedByToolRequest?: boolean } { + const states = PENDING_JOB_STATES; + const placeholders = states.map(() => '?').join(','); + const tx = db.transaction((): { job: Job; created: boolean; blockedByToolRequest?: boolean } => { + // Approval-pause guard: atomically refuse to create a normal resume job + // while a job is parked for tool OR package approval (both resolved via + // approve/deny, which re-queues the original). Done inside the same + // transaction as the insert so there is no check-then-act race with the + // engine parking the job. + if (opts?.blockOnToolRequestPause) { + const parked = db + .prepare( + `SELECT * FROM jobs WHERE repo = ? AND issue_number = ? + AND status = 'waiting_human' AND wait_reason IN ('tool_request', 'package_request') + ORDER BY created_at DESC, rowid DESC LIMIT 1`, + ) + .get(params.repo, params.issueNumber) as JobRow | undefined; + if (parked) return { job: rowToJob(parked), created: false, blockedByToolRequest: true }; + } + const existing = db + .prepare( + `SELECT * FROM jobs WHERE repo = ? AND issue_number = ? AND status IN (${placeholders}) + ORDER BY created_at DESC, rowid DESC LIMIT 1`, + ) + .get(params.repo, params.issueNumber, ...states) as JobRow | undefined; + if (existing) return { job: rowToJob(existing), created: false }; + return { job: insertJobSync(db, params), created: true }; + }); + return tx(); + } + + + export async function getJob(db: Database.Database, id: string, opts?: { viewer?: Express.User }): Promise { + const viewerClause = opts?.viewer + ? buildVisibilityWhere(opts.viewer, 'j', { spaceColumn: 'space_id' }) + : { clause: '1=1', params: [] as unknown[] }; + const row = db + .prepare(`SELECT j.* FROM jobs j WHERE j.id = ? AND ${viewerClause.clause}`) + .get(id, ...viewerClause.params) as JobRow | undefined; + return row ? rowToJob(row) : null; + } + + + /** サブジョブ一覧を SubtaskInfo[] に変換。waiting_subtasks の子は再帰的に children を取得する */ + export async function buildSubtaskInfos(db: Database.Database, subJobs: Job[], maxDepth: number = 3): Promise { + return Promise.all(subJobs.map(async (j): Promise => { + const info: SubtaskInfo = { + id: j.id, + issueNumber: j.issueNumber, + status: j.status, + instruction: j.instruction, + worktreePath: j.worktreePath, + createdAt: j.createdAt, + updatedAt: j.updatedAt, + }; + // 再帰: waiting_subtasks のサブタスクは孫タスク情報も取得 + if (j.status === 'waiting_subtasks' && maxDepth > 0) { + const grandChildren = await getSubJobs(db, j.id); + if (grandChildren.length > 0) { + info.children = await buildSubtaskInfos(db, grandChildren, maxDepth - 1); + info.childCount = grandChildren.length; + info.childCompleted = grandChildren.filter(g => + ['succeeded', 'failed', 'cancelled'].includes(g.status) + ).length; + } + } + return info; + })); + } + + + export function getJobSync(db: Database.Database, id: string): Job | null { + const row = db + .prepare('SELECT * FROM jobs WHERE id = ?') + .get(id) as JobRow | undefined; + return row ? rowToJob(row) : null; + } + + + /** + * ジョブの現在のステータスを同期的に取得する。 + * キャンセルチェックなど、非同期が使えない箇所で利用する。 + */ + export function getJobStatusSync(db: Database.Database, id: string): JobStatus | null { + const row = db + .prepare('SELECT status FROM jobs WHERE id = ?') + .get(id) as { status: string } | undefined; + return row ? (row.status as JobStatus) : null; + } + + + /** + * Returns true if `workerId` currently has at least one job with status='running'. + * Used by the Side Info Panel's worker status endpoint. Boolean-only on purpose: + * never expose the job id/title/owner to other users in the shared panel. + */ + export function isWorkerBusy(db: Database.Database, workerId: string): boolean { + const row = db + .prepare(`SELECT 1 AS hit FROM jobs WHERE worker_id = ? AND status = 'running' LIMIT 1`) + .get(workerId) as { hit: number } | undefined; + return !!row; + } + + + /** + * いま実行中 (status='running') のジョブを worker_id ごとにまとめ、占有ユーザーの + * 表示名と種別 (`task_kind`: 'agent' 通常 / 'reflection' 学習 等) を返す。 + * **admin 専用の監視用**(ワーカー / GPU パネルで「誰が・何で GPU を占有しているか」を + * 出す)。一般ユーザーには呼び出し側で渡さない(dashboard-workers の privacy 既定を維持)。 + * + * `user` は `users.name`。null(owner 不明 / no-auth 'local')は owner_id →'unknown' + * の順でフォールバック。`(user, kind)` の組で重複排除し、1 ワーカーに複数ユーザー / + * 複数種別が同居するケースに対応する。 + */ + export function listRunningJobOwnersByWorker(db: Database.Database): Map> { + const rows = db + .prepare( + `SELECT j.worker_id AS workerId, j.owner_id AS ownerId, j.task_kind AS taskKind, u.name AS ownerName + FROM jobs j + LEFT JOIN users u ON u.id = j.owner_id + WHERE j.status = 'running' AND j.worker_id IS NOT NULL`, + ) + .all() as Array<{ workerId: string; ownerId: string | null; taskKind: string | null; ownerName: string | null }>; + const byWorker = new Map>(); + for (const r of rows) { + const user = (r.ownerName && r.ownerName.trim()) || r.ownerId || 'unknown'; + const kind = r.taskKind || 'agent'; + const list = byWorker.get(r.workerId); + if (list) { + if (!list.some((o) => o.user === user && o.kind === kind)) list.push({ user, kind }); + } else { + byWorker.set(r.workerId, [{ user, kind }]); + } + } + return byWorker; + } + + + export async function updateJob(db: Database.Database, id: string, updates: Partial>): Promise { + const setClauses: string[] = ["updated_at = datetime('now')"]; + const params: Record = { id }; + + const fieldMap: Record = { + status: 'status', + pieceName: 'piece_name', + currentMovement: 'current_movement', + currentActivity: 'current_activity', + instruction: 'instruction', + branchName: 'branch_name', + worktreePath: 'worktree_path', + prNumber: 'pr_number', + attempt: 'attempt', + maxAttempts: 'max_attempts', + nextRetryAt: 'next_retry_at', + errorSummary: 'error_summary', + abortReason: 'abort_reason', + resumeMovement: 'resume_movement', + waitReason: 'wait_reason', + askCount: 'ask_count', + workerId: 'worker_id', + lastBackendId: 'last_backend_id', + parentJobId: 'parent_job_id', + subtaskDepth: 'subtask_depth', + requiredRole: 'required_profile', + requiredProfile: 'required_profile', + }; + + for (const [jsKey, dbCol] of Object.entries(fieldMap)) { + const val = (updates as Record)[jsKey]; + if (val !== undefined) { + setClauses.push(`${dbCol} = @${jsKey}`); + params[jsKey] = val; + } + } + + if (setClauses.length === 1) return; // updated_at のみ = 実質変更なし + + db + .prepare(`UPDATE jobs SET ${setClauses.join(', ')} WHERE id = @id`) + .run(params); + } + + + /** ジョブの updated_at のみを更新(ハートビート用)。updateJob は変更フィールドなしだと早期リターンするため別メソッド */ + export function touchJobUpdatedAt(db: Database.Database, id: string): void { + db.prepare("UPDATE jobs SET updated_at = datetime('now') WHERE id = ?").run(id); + } + + + /** + * Re-queue jobs parked with wait_reason='mcp_auth_required' for the given owner. + * Worker re-evaluates required_mcp on next pickup and will pause again if other servers + * are still unauthorized. _serverId is accepted for API symmetry but not used at SQL time + * (filtering by piece's required_mcp happens at the worker side). + * + * Returns the number of jobs actually re-queued. + */ + export function resumeMcpWaitingJobs(db: Database.Database, ownerId: string, _serverId: string): number { + const result = db + .prepare( + `UPDATE jobs + SET status='queued', wait_reason=NULL, updated_at=datetime('now') + WHERE status='waiting_human' AND wait_reason='mcp_auth_required' + AND owner_id = ?`, + ) + .run(ownerId); + return result.changes; + } + + + /** + * Re-queue a job parked with wait_reason='tool_request' after a user + * decided (approve/deny) the pending tool request. The worker resumes from + * `resume_movement` and re-reads the task's granted_tools. Returns the number + * of jobs re-queued (0 if the job is gone or no longer parked for approval). + */ + export function resumeToolRequestJob(db: Database.Database, jobId: string): number { + const result = db + .prepare( + `UPDATE jobs + SET status='queued', wait_reason=NULL, updated_at=datetime('now') + WHERE id = ? AND status='waiting_human' AND wait_reason='tool_request'`, + ) + .run(jobId); + return result.changes; + } + + /** + * Re-queue a job parked with wait_reason='package_request' after a user + * decided (approve/deny) the pending Python package request. On approve the + * package is already installed into the space overlay, so the resumed + * movement re-runs with the wheel importable. Returns the number of jobs + * re-queued (0 if the job is gone or no longer parked for this reason). + */ + export function resumePackageRequestJob(db: Database.Database, jobId: string): number { + const result = db + .prepare( + `UPDATE jobs + SET status='queued', wait_reason=NULL, updated_at=datetime('now') + WHERE id = ? AND status='waiting_human' AND wait_reason='package_request'`, + ) + .run(jobId); + return result.changes; + } + + + export async function lockIssue(db: Database.Database, repo: string, issueNumber: number, jobId: string): Promise { + try { + db + .prepare('INSERT INTO issue_locks (repo, issue_number, job_id) VALUES (?, ?, ?)') + .run(repo, issueNumber, jobId); + return true; + } catch { + return false; + } + } + + + export async function unlockIssue(db: Database.Database, repo: string, issueNumber: number): Promise { + db + .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') + .run(repo, issueNumber); + } + + + export async function deleteJobsForIssue(db: Database.Database, repo: string, issueNumber: number): Promise { + const result = db + .prepare('DELETE FROM jobs WHERE repo = ? AND issue_number = ?') + .run(repo, issueNumber); + db + .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') + .run(repo, issueNumber); + return result.changes; + } + + + export async function claimNextJob(db: Database.Database, workerId: string): Promise { + const row = db.prepare(` + UPDATE jobs + SET status = 'running', worker_id = ?, updated_at = datetime('now') + WHERE id = ( + SELECT j.id + FROM jobs j + JOIN worker_nodes w ON w.worker_id = ? + WHERE j.status = 'queued' + AND w.enabled = 1 + AND w.healthy = 1 + AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND NOT EXISTS ( + SELECT 1 FROM issue_locks il + WHERE il.repo = j.repo AND il.issue_number = j.issue_number + ) + ORDER BY j.created_at ASC + LIMIT 1 + ) + RETURNING * + `).get(workerId, workerId) as JobRow | undefined; + return row ? rowToJob(row) : null; + } + + + /** + * リトライ待ちジョブの中から next_retry_at を過ぎたものを1件取得して running に遷移 + */ + export async function claimNextRetryJob(db: Database.Database, workerId: string): Promise { + const row = db.prepare(` + UPDATE jobs + SET status = 'running', worker_id = ?, updated_at = datetime('now') + WHERE id = ( + SELECT j.id + FROM jobs j + JOIN worker_nodes w ON w.worker_id = ? + WHERE j.status = 'retry' + AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') + AND w.enabled = 1 + AND w.healthy = 1 + AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND NOT EXISTS ( + SELECT 1 FROM issue_locks il + WHERE il.repo = j.repo AND il.issue_number = j.issue_number + ) + ORDER BY j.next_retry_at ASC + LIMIT 1 + ) + RETURNING * + `).get(workerId, workerId) as JobRow | undefined; + return row ? rowToJob(row) : null; + } + + + /** + * Read-only peek at the next job this worker WOULD claim (retry-priority, + * then oldest queued), without claiming it. Used by the idle-preferring + * claim gate to learn the next job's role before deciding whether to defer + * to an idler sibling. Mirrors the claimNext*Job WHERE clauses exactly. + */ + export async function peekNextClaimable(db: Database.Database, workerId: string): Promise { + const retry = db.prepare(` + SELECT j.* + FROM jobs j + JOIN worker_nodes w ON w.worker_id = ? + WHERE j.status = 'retry' + AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') + AND w.enabled = 1 + AND w.healthy = 1 + AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND NOT EXISTS ( + SELECT 1 FROM issue_locks il + WHERE il.repo = j.repo AND il.issue_number = j.issue_number + ) + ORDER BY j.next_retry_at ASC + LIMIT 1 + `).get(workerId) as JobRow | undefined; + if (retry) return rowToJob(retry); + + const queued = db.prepare(` + SELECT j.* + FROM jobs j + JOIN worker_nodes w ON w.worker_id = ? + WHERE j.status = 'queued' + AND w.enabled = 1 + AND w.healthy = 1 + AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 + AND NOT EXISTS ( + SELECT 1 FROM issue_locks il + WHERE il.repo = j.repo AND il.issue_number = j.issue_number + ) + ORDER BY j.created_at ASC + LIMIT 1 + `).get(workerId) as JobRow | undefined; + return queued ? rowToJob(queued) : null; + } + + + export async function getJobsByStatus(db: Database.Database, status: JobStatus): Promise { + const rows = db + .prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC') + .all(status) as JobRow[]; + return rows.map(rowToJob); + } + + + export async function getLatestJobForIssue(db: Database.Database, repo: string, issueNumber: number): Promise { + const row = db + .prepare('SELECT * FROM jobs WHERE repo = ? AND issue_number = ? ORDER BY created_at DESC LIMIT 1') + .get(repo, issueNumber) as JobRow | undefined; + return row ? rowToJob(row) : null; + } + + + export async function updateJobContext(db: Database.Database, jobId: string, payload: { promptTokens: number; limitTokens: number }): Promise { + const updatedAt = new Date().toISOString(); + db + .prepare( + `UPDATE jobs + SET context_prompt_tokens = ?, + context_limit_tokens = ?, + context_updated_at = ? + WHERE id = ?` + ) + .run(payload.promptTokens, payload.limitTokens, updatedAt, jobId); + } + + + /** 起動時に孤立ジョブを回復 */ + export async function recoverOrphanedJobs(db: Database.Database): Promise { + const result = db + .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") + .run(); + if (result.changes > 0) { + db.prepare('DELETE FROM issue_locks').run(); + logger.warn(`Repository: recovered ${result.changes} orphaned jobs, cleared issue locks`); + } + // waiting_subtasks のジョブで全サブジョブが完了済みのものを再キュー + // 同一 issue_number に複数ジョブがある場合、最新のみで判定する + const subtaskRecovery = db.prepare(` + UPDATE jobs + SET status = 'queued', updated_at = datetime('now') + WHERE status = 'waiting_subtasks' + AND ( + SELECT COUNT(*) FROM ( + SELECT s.status, ROW_NUMBER() OVER ( + PARTITION BY s.issue_number + ORDER BY s.created_at DESC, s.rowid DESC + ) AS rn + FROM jobs s + WHERE s.parent_job_id = jobs.id + ) WHERE rn = 1 + AND status NOT IN ('succeeded','failed','cancelled') + ) = 0 + `).run(); + if (subtaskRecovery.changes > 0) { + logger.warn(`Repository: recovered ${subtaskRecovery.changes} waiting_subtasks jobs`); + } + return result.changes; + } + + + /** + * running/dispatching 状態のまま staleMinutes 以上 updated_at が更新されていないジョブを + * queued に戻す(ランタイム watchdog) + */ + export function recoverStuckRunningJobs(db: Database.Database, staleMinutes: number): number { + const rows = db.prepare(` + UPDATE jobs + SET status = 'queued', + worker_id = NULL, + error_summary = 'Recovered: stuck in running for over ' || ? || ' minutes', + updated_at = datetime('now') + WHERE status IN ('running', 'dispatching') + AND updated_at < datetime('now', '-' || ? || ' minutes') + RETURNING id, repo + `).all(staleMinutes, staleMinutes) as Array<{ id: string; repo: string }>; + if (rows.length > 0) { + // issue ロックも解除 + for (const row of rows) { + db.prepare('DELETE FROM issue_locks WHERE job_id = ?').run(row.id); + } + logger.warn(`Repository: recovered ${rows.length} stuck jobs (stale > ${staleMinutes}min): ${rows.map(r => r.repo).join(', ')}`); + } + return rows.length; + } + + + /** running/dispatching 状態のジョブを全て queued に戻す(graceful shutdown 用) */ + export function requeueRunningJobs(db: Database.Database): number { + const result = db + .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") + .run(); + if (result.changes > 0) { + db.prepare('DELETE FROM issue_locks').run(); + logger.warn(`Repository: requeued ${result.changes} running jobs, cleared issue locks`); + } + return result.changes; + } + + + export function getDistinctRepos(db: Database.Database): string[] { + const rows = db.prepare('SELECT DISTINCT repo FROM jobs ORDER BY repo').all() as { repo: string }[]; + return rows.map(r => r.repo); + } + + + export function getJobsByRepo(db: Database.Database, repoName: string): Job[] { + const rows = db.prepare('SELECT * FROM jobs WHERE repo = ? ORDER BY created_at DESC').all(repoName) as JobRow[]; + return rows.map(rowToJob); + } + + + /** Issue ごとに最新の Job だけを返す(カンバンUI用) */ + export function getLatestJobsPerIssue(db: Database.Database, repoName: string): Job[] { + const rows = db.prepare(` + SELECT j.* + FROM jobs j + WHERE j.repo = ? + AND j.id = ( + SELECT j2.id + FROM jobs j2 + WHERE j2.repo = j.repo + AND j2.issue_number = j.issue_number + ORDER BY j2.updated_at DESC, j2.created_at DESC, j2.rowid DESC + LIMIT 1 + ) + ORDER BY j.updated_at DESC + `).all(repoName) as JobRow[]; + return rows.map(rowToJob); + } + + + // Cascade a local_task visibility change to all jobs spawned for that task + // and their recursive subtask descendants (repo='subtask/'). + // Returns the number of job rows updated. + export async function updateJobsVisibilityForTask(db: Database.Database, taskId: number, updates: { visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }): Promise { + const repoName = localTaskRepoName(taskId); + const now = new Date().toISOString(); + const result = db + .prepare(` + WITH RECURSIVE job_tree(id) AS ( + SELECT id FROM jobs WHERE repo = ? + UNION ALL + SELECT j.id FROM jobs j JOIN job_tree jt ON j.parent_job_id = jt.id + ) + UPDATE jobs + SET visibility = ?, + visibility_scope_org_id = ?, + updated_at = ? + WHERE id IN (SELECT id FROM job_tree) + `) + .run(repoName, updates.visibility, updates.visibilityScopeOrgId, now); + return result.changes; + } + + + export async function getSubJobs(db: Database.Database, parentJobId: string): Promise { + // 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す + // ROW_NUMBER() + rowid で同一 created_at でも一意に決定する + const rows = db + .prepare(` + SELECT * FROM ( + SELECT j.*, ROW_NUMBER() OVER ( + PARTITION BY j.issue_number + ORDER BY j.created_at DESC, j.rowid DESC + ) AS rn + FROM jobs j + WHERE j.parent_job_id = ? + ) WHERE rn = 1 + ORDER BY issue_number ASC + `) + .all(parentJobId) as JobRow[]; + return rows.map(rowToJob); + } + + + /** + * 全サブジョブが終端状態なら親ジョブを再キューに戻す。 + * 再キューできた場合 true を返す。 + */ + export async function requeueParentJobIfAllSubtasksDone(db: Database.Database, parentJobId: string): Promise { + // 同一 issue_number に複数ジョブがある場合、最新のもの(ROW_NUMBER=1)のみで判定する + const result = db.prepare(` + UPDATE jobs + SET status = 'queued', + updated_at = datetime('now') + WHERE id = ? + AND status = 'waiting_subtasks' + AND ( + SELECT COUNT(*) FROM ( + SELECT status, ROW_NUMBER() OVER ( + PARTITION BY issue_number + ORDER BY created_at DESC, rowid DESC + ) AS rn + FROM jobs + WHERE parent_job_id = ? + ) WHERE rn = 1 + AND status NOT IN ('succeeded', 'failed', 'cancelled') + ) = 0 + `).run(parentJobId, parentJobId); + return result.changes > 0; + } + + + /** + * 実行中のジョブをキャンセル状態に変更する。 + * running または dispatching 状態のジョブのみ対象。 + * 戻り値: キャンセル対象ジョブが見つかったら true、見つからなかったら false。 + */ + export function requestJobCancel(db: Database.Database, jobId: string): boolean { + const result = db.prepare(` + UPDATE jobs + SET status = 'cancelled', updated_at = datetime('now') + WHERE id = ? AND status IN ('running', 'dispatching') + `).run(jobId); + return result.changes > 0; + } diff --git a/src/db/repositories/llm-usage.ts b/src/db/repositories/llm-usage.ts new file mode 100644 index 0000000..eb5c8ea --- /dev/null +++ b/src/db/repositories/llm-usage.ts @@ -0,0 +1,254 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// llm-usage repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; + +/** Per-call delta for the per-user daily LLM usage ledger. */ +export interface LlmUsageIncrement { + /** UTC day bucket 'YYYY-MM-DD'. Defaults to today (UTC) when omitted. */ + day?: string; + /** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */ + userId: string; + source: 'gateway' | 'direct'; + /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ + model: string; + /** Backend server name (gateway backendId / direct host), or 'unknown'. */ + route: string; + tokensIn?: number; + tokensOut?: number; + requests?: number; + at?: string; +} + + +/** Daily-grouped aggregate row (model/route collapsed) for the usage API. */ +export interface LlmUsageDailyAgg { + day: string; + userId: string; + source: string; + tokensIn: number; + tokensOut: number; + requests: number; +} + + +/** Hour-grain UPSERT input for the v2 usage ledger. */ +export interface LlmUsageHourlyIncrement { + /** UTC hour bucket 'YYYY-MM-DDTHH'. Defaults to the current hour (UTC). */ + hour?: string; + /** Owner id, 'local' / 'system' sentinel, or 'gw:' for downstream gateway consumers. */ + userId: string; + /** + * 'direct' / 'gateway' are this orchestrator's own calls. 'gateway_downstream' + * is traffic the AAO Gateway proxied for an EXTERNAL consumer (recorded from + * the gateway proxy path, keyed by team) — kept distinct so it never merges + * with our own usage. See docs/superpowers/specs/2026-06-19-usage-hourly-profile-and-gateway-downstream-design.md + */ + source: 'gateway' | 'direct' | 'gateway_downstream'; + /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ + model: string; + /** Backend server name (gateway backendId / direct host), or 'unknown'. */ + route: string; + tokensIn?: number; + tokensOut?: number; + requests?: number; + /** ISO timestamp for last_updated_at + hour default. Defaults to now. */ + at?: string; +} + + +/** + * Raw hour-grain ledger row (no axis collapsed) for the usage API. The API + * re-buckets `hour` into the viewer's local calendar period and groups by + * whichever of source/model/route/user/org the request asked for. + */ +export interface LlmUsageHourlyRow { + hour: string; + userId: string; + source: string; + model: string; + route: string; + tokensIn: number; + tokensOut: number; + requests: number; +} + + + // ── Per-user daily LLM usage (gateway + direct) ────────────────────── + // + // Recorded at the OpenAICompatClient completion boundary for every + // successful chat completion. UPSERT on the (day, user_id, source, + // model, route) grain. Separate lens from gateway_key_usage — never + // summed across the two tables. Spec: + // docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md + + /** + * UPSERT: bump per-(day, user, source, model, route) counters. Deltas + * are clamped at zero. `day` defaults to the UTC day of `at` (or now). + * Called once per successful stream completion; `usage`-less completions + * still bump `requests` (tokens 0) so a 0-token request is distinct from + * a failed/aborted one (which is never recorded). + */ + export function incrementLlmUsage(db: Database.Database, params: LlmUsageIncrement): void { + const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); + const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); + const reqs = Math.max(0, Math.floor(params.requests ?? 1)); + const ts = params.at ?? new Date().toISOString(); + const day = params.day ?? ts.slice(0, 10); + db + .prepare( + `INSERT INTO llm_usage_daily + (day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (day, user_id, source, model, route) DO UPDATE SET + tokens_in = tokens_in + excluded.tokens_in, + tokens_out = tokens_out + excluded.tokens_out, + requests = requests + excluded.requests, + last_updated_at = excluded.last_updated_at`, + ) + .run(day, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); + } + + + /** + * Daily time series for the usage dashboard, grouped by (day, user_id, + * source) with model/route collapsed. `userId` filter scopes a non-admin + * to their own rows; omit it for the admin all-users view (callers can + * collapse user_id afterwards). Inclusive `from`/`to` are 'YYYY-MM-DD'. + */ + export function queryLlmUsageDaily(db: Database.Database, opts: { from: string; to: string; userId?: string }): LlmUsageDailyAgg[] { + const where = ['day >= ?', 'day <= ?']; + const args: unknown[] = [opts.from, opts.to]; + if (opts.userId !== undefined) { + where.push('user_id = ?'); + args.push(opts.userId); + } + const rows = db + .prepare( + `SELECT day, user_id, source, + SUM(tokens_in) AS tokens_in, + SUM(tokens_out) AS tokens_out, + SUM(requests) AS requests + FROM llm_usage_daily + WHERE ${where.join(' AND ')} + GROUP BY day, user_id, source + ORDER BY day ASC`, + ) + .all(...args) as Array<{ + day: string; + user_id: string; + source: string; + tokens_in: number; + tokens_out: number; + requests: number; + }>; + return rows.map((r) => ({ + day: r.day, + userId: r.user_id, + source: r.source, + tokensIn: r.tokens_in, + tokensOut: r.tokens_out, + requests: r.requests, + })); + } + + + /** + * v2 write path: UPSERT per-(hour, user, source, model, route) counters. + * `hour` defaults to the current UTC hour 'YYYY-MM-DDTHH'. Same contract as + * incrementLlmUsage (deltas clamped at zero; usage-less completions still + * bump `requests`). Supersedes incrementLlmUsage as the recorder target. + */ + export function incrementLlmUsageHourly(db: Database.Database, params: LlmUsageHourlyIncrement): void { + const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); + const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); + const reqs = Math.max(0, Math.floor(params.requests ?? 1)); + const ts = params.at ?? new Date().toISOString(); + const hour = params.hour ?? ts.slice(0, 13); + db + .prepare( + `INSERT INTO llm_usage_hourly + (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (hour, user_id, source, model, route) DO UPDATE SET + tokens_in = tokens_in + excluded.tokens_in, + tokens_out = tokens_out + excluded.tokens_out, + requests = requests + excluded.requests, + last_updated_at = excluded.last_updated_at`, + ) + .run(hour, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); + } + + + /** + * Raw hour-grain rows for the usage dashboard v2, no axis collapsed so the + * API can group by any of source/model/route/user/org and re-bucket into the + * viewer's local timezone. `userId` scopes a non-admin to their own rows. + * Inclusive `fromHour`/`toHour` are 'YYYY-MM-DDTHH' (UTC) — callers widen the + * UTC window by ±1 day before filtering precisely against local days. + */ + export function queryLlmUsageHourly(db: Database.Database, opts: { + fromHour: string; + toHour: string; + userId?: string; + }): LlmUsageHourlyRow[] { + const where = ['hour >= ?', 'hour <= ?']; + const args: unknown[] = [opts.fromHour, opts.toHour]; + if (opts.userId !== undefined) { + where.push('user_id = ?'); + args.push(opts.userId); + } + const rows = db + .prepare( + `SELECT hour, user_id, source, model, route, tokens_in, tokens_out, requests + FROM llm_usage_hourly + WHERE ${where.join(' AND ')} + ORDER BY hour ASC`, + ) + .all(...args) as Array<{ + hour: string; + user_id: string; + source: string; + model: string; + route: string; + tokens_in: number; + tokens_out: number; + requests: number; + }>; + return rows.map((r) => ({ + hour: r.hour, + userId: r.user_id, + source: r.source, + model: r.model, + route: r.route, + tokensIn: r.tokens_in, + tokensOut: r.tokens_out, + requests: r.requests, + })); + } + + + /** + * Map every user to a single org label for the usage dashboard's "by org" + * breakdown. Unions Gitea org cache (user_gitea_orgs) and local orgs + * (local_org_members → local_orgs.name); a multi-org user collapses to the + * alphabetically-first org name (MIN) so the breakdown is deterministic. + * Users with no org (and the 'local'/'system' sentinels) are simply absent — + * the API buckets them under 'no-org'. + */ + export function getUsageOrgMap(db: Database.Database): Map { + const rows = db + .prepare( + `SELECT user_id, MIN(org_name) AS org_name FROM ( + SELECT user_id, org_name FROM user_gitea_orgs + UNION ALL + SELECT m.user_id AS user_id, o.name AS org_name + FROM local_org_members m + JOIN local_orgs o ON o.id = m.org_id + ) + GROUP BY user_id`, + ) + .all() as Array<{ user_id: string; org_name: string }>; + const map = new Map(); + for (const r of rows) map.set(r.user_id, r.org_name); + return map; + } diff --git a/src/db/repositories/local-tasks.ts b/src/db/repositories/local-tasks.ts new file mode 100644 index 0000000..dc245cf --- /dev/null +++ b/src/db/repositories/local-tasks.ts @@ -0,0 +1,965 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// local-tasks repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { rmSync, existsSync, realpathSync } from 'fs'; +import { join, normalize, sep } from 'path'; +import { randomUUID } from 'crypto'; +import { logger } from '../../logger.js'; +import { buildVisibilityWhere } from '../../bridge/visibility.js'; +import { buildTitleFromGoal } from '../../title-generation.js'; +import { utc, localTaskRepoName } from './shared.js'; +import { Job, SubtaskInfo, JobRow, rowToJob } from './jobs.js'; +import * as taskSearchRepo from './task-search.js'; +import * as jobsRepo from './jobs.js'; +import * as spacesRepo from './spaces.js'; + +/** + * Shared SQL fragments for LocalTask read queries. + * + * getLocalTask / listLocalTasks / getLocalTaskByShareToken all need to expose + * the owner's display name and the org display name of `visibility_scope_org_id`. + * These constants keep the three queries in sync. + * + * Usage: splice into the SELECT list and the FROM-clause joins, e.g. + * + * SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT} + * FROM local_tasks lt + * ${LOCAL_TASK_DISPLAY_JOIN} + * WHERE ... + * + * A correlated subquery (`MIN(org_name)`) is used instead of a JOIN because + * user_gitea_orgs is keyed per-user, and we only need any one display name + * for the org id — this avoids row-multiplication across the join. + */ +export const LOCAL_TASK_DISPLAY_SELECT = ` + u.name AS owner_name, + COALESCE( + (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id), + (SELECT name FROM local_orgs WHERE id = lt.visibility_scope_org_id) + ) AS visibility_scope_org_name +`.trim(); + +export const LOCAL_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = lt.owner_id`; + + +export type TitleSource = 'auto' | 'agent' | 'user'; + + +export interface LocalTask { + id: number; + title: string; + /** Provenance of `title`. 'user' is never auto-overwritten by the agent. */ + titleSource: TitleSource; + body: string; + pieceName: string; + profile: 'auto' | 'fast' | 'quality' | string; + outputFormat: 'text' | 'markdown' | 'json' | string; + askPolicy: 'low' | 'high' | string; + priority: 'low' | 'medium' | 'high' | string; + state: 'open' | 'closed' | string; + workspacePath: string | null; + /** 実行先ワークスペースの種類。persistent=スペース共有ツリー / ephemeral=使い捨て。 */ + workspaceMode: 'persistent' | 'ephemeral'; + ownerId: string | null; + ownerName?: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + visibilityScopeOrgName?: string | null; + /** 所属スペース。null は owner の個人スペース(=現行の data/users/{owner})に解決される。 */ + spaceId: string | null; + /** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */ + runtimeDir: string | null; + createdAt: string; + updatedAt: string; + feedbackRating: 'good' | 'bad' | null; + feedbackTags: string[] | null; + feedbackComment: string | null; + feedbackAt: string | null; + shareToken: string | null; + sharedAt: string | null; + browserSessionProfileId?: number | null; + /** + * Mission Brief: per-task pinned memo. Carries goal / done / open / + * clarifications. Always rendered at the top of every movement's + * system prompt. The LLM updates it via mission_update; the user + * edits it from the Overview tab. + */ + missionBrief: MissionBrief | null; + /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). */ + options: Record; + latestJob?: Job | null; + subtasks?: SubtaskInfo[]; + subtaskCount?: number; + subtaskCompleted?: number; +} + + +/** + * Ordered Mission Brief field list. Single source of truth for parse / update + * / API whitelist / MissionUpdate tool so a future field is a one-line + * addition here (plus its typed interface member). Legacy fields come first so + * JSON key order and title-derivation (goal only) stay unchanged. + */ +export const MISSION_BRIEF_FIELDS = [ + 'goal', + 'done', + 'open', + 'clarifications', + 'user_constraints', + 'decisions', + 'current_focus', +] as const; + + +export type MissionBriefField = (typeof MISSION_BRIEF_FIELDS)[number]; + + +export interface MissionBrief { + goal: string; + done: string; + open: string; + clarifications: string; + /** Durable user-stated constraints ("do not change X"). */ + user_constraints?: string; + /** Decisions made after clarification, with rationale. */ + decisions?: string; + /** What this movement is actively working on right now. */ + current_focus?: string; +} + + +export interface LocalTaskComment { + id: number; + taskId: number; + author: string; + kind: string; + body: string; + /** Filenames saved to the task's input/ dir for this comment (UI download links). */ + attachments: string[]; + createdAt: string; + injectedAt: string | null; +} + + +export interface CreateLocalTaskParams { + title: string; + /** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */ + titleSource?: TitleSource; + body: string; + pieceName?: string; + profile?: 'auto' | 'fast' | 'quality'; + outputFormat?: 'text' | 'markdown' | 'json'; + askPolicy?: 'low' | 'high'; + priority?: 'low' | 'medium' | 'high'; + workspacePath?: string | null; + /** ワークスペースの種類。未指定は 'persistent'(スペース共有)。 */ + workspaceMode?: 'persistent' | 'ephemeral'; + ownerId?: string | null; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + browserSessionProfileId?: number | null; + /** 所属スペース。未指定(null)は owner の個人スペースに解決される。 */ + spaceId?: string | null; + /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */ + options?: Record; +} + + +export interface LocalTaskRow { + id: number; + title: string; + title_source: string | null; + body: string; + piece_name: string; + profile: string; + output_format: string; + ask_policy: string; + priority: string; + state: string; + workspace_path: string | null; + workspace_mode: string | null; + runtime_dir: string | null; + owner_id: string | null; + owner_name?: string | null; + visibility: string | null; + visibility_scope_org_id: string | null; + visibility_scope_org_name?: string | null; + space_id: string | null; + created_at: string; + updated_at: string; + feedback_rating: string | null; + feedback_comment: string | null; + feedback_tags: string | null; + feedback_at: string | null; + share_token: string | null; + shared_at: string | null; + mission_brief: string | null; + browser_session_profile_id: number | null; + options: string | null; +} + + +export interface LocalTaskCommentRow { + id: number; + task_id: number; + author: string; + kind: string; + body: string; + attachments: string | null; + created_at: string; + injected_at: string | null; +} + + +export function rowToLocalTask(row: LocalTaskRow): LocalTask { + return { + id: row.id, + title: row.title, + titleSource: (row.title_source as TitleSource | null) ?? 'auto', + body: row.body, + pieceName: row.piece_name, + profile: row.profile, + outputFormat: row.output_format, + askPolicy: row.ask_policy, + priority: row.priority, + state: row.state, + workspacePath: row.workspace_path, + workspaceMode: row.workspace_mode === 'ephemeral' ? 'ephemeral' : 'persistent', + ownerId: row.owner_id ?? null, + ownerName: row.owner_name ?? null, + visibility: (row.visibility ?? 'private') as LocalTask['visibility'], + visibilityScopeOrgId: row.visibility_scope_org_id ?? null, + visibilityScopeOrgName: row.visibility_scope_org_name ?? null, + spaceId: row.space_id ?? null, + runtimeDir: row.runtime_dir ?? null, + createdAt: utc(row.created_at), + updatedAt: utc(row.updated_at), + feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null, + feedbackTags: row.feedback_tags ? JSON.parse(row.feedback_tags) : null, + feedbackComment: row.feedback_comment ?? null, + feedbackAt: row.feedback_at ? utc(row.feedback_at) : null, + shareToken: row.share_token ?? null, + sharedAt: row.shared_at ? utc(row.shared_at) : null, + browserSessionProfileId: row.browser_session_profile_id ?? null, + missionBrief: parseMissionBrief(row.mission_brief), + options: parseTaskOptions(row.options), + }; +} + + +export function parseMissionBrief(raw: string | null | undefined): MissionBrief | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + const brief = {} as MissionBrief; + for (const field of MISSION_BRIEF_FIELDS) { + brief[field] = typeof parsed[field] === 'string' ? parsed[field] : ''; + } + if (MISSION_BRIEF_FIELDS.every((field) => !brief[field])) return null; + return brief; + } catch { + return null; + } +} + + +export function parseTaskOptions(raw: string | null | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } catch { + return {}; + } +} + + +export function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment { + return { + id: row.id, + taskId: row.task_id, + author: row.author, + kind: row.kind, + body: row.body, + attachments: parseCommentAttachments(row.attachments), + createdAt: utc(row.created_at), + injectedAt: row.injected_at ? utc(row.injected_at) : null, + }; +} + + +/** Parse the JSON attachments column to a string[]; tolerate null/legacy/garbage. */ +export function parseCommentAttachments(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : []; + } catch { + return []; + } +} + + + /** + * タスクの実効スペースを解決し FolderContext を返す。 + * - space_id があればそのスペース、無ければ owner の個人スペース(無ければ lazy 生成)。 + * - owner も無い(no-auth)の場合は 'local' の個人スペース。 + * 返り値は userPiecesDir/readUserAgentsMd 等にそのまま渡せる { rootDir, leafId }。 + */ + export async function resolveTaskFolderContext(db: Database.Database, taskId: number, userFolderRoot: string): Promise { + const { resolveSpaceFolder } = await import('../../spaces/folder-resolver.js'); + const row = db + .prepare(`SELECT space_id, owner_id FROM local_tasks WHERE id = ?`) + .get(taskId) as { space_id: string | null; owner_id: string | null } | undefined; + const ownerId = row?.owner_id ?? 'local'; + if (row?.space_id) { + const space = await spacesRepo.getSpace(db, row.space_id); + if (space) return resolveSpaceFolder(space, userFolderRoot); + // 参照先スペースが消えている場合は個人スペースにフォールバック + } + const personal = await spacesRepo.ensurePersonalSpace(db, ownerId); + return resolveSpaceFolder(personal, userFolderRoot); + } + + + export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise { + const result = db + .prepare( + `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options) + VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)` + ) + .run({ + title: params.title, + titleSource: params.titleSource ?? 'auto', + body: params.body, + pieceName: params.pieceName ?? 'chat', + profile: params.profile ?? 'auto', + outputFormat: params.outputFormat ?? 'markdown', + askPolicy: params.askPolicy ?? 'low', + priority: params.priority ?? 'medium', + workspacePath: params.workspacePath ?? null, + workspaceMode: params.workspaceMode ?? 'persistent', + ownerId: params.ownerId ?? null, + visibility: params.visibility ?? 'private', + visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, + browserSessionProfileId: params.browserSessionProfileId ?? null, + spaceId: params.spaceId ?? null, + options: JSON.stringify(params.options ?? {}), + }); + + const task = await getLocalTask(db, Number(result.lastInsertRowid)); + if (!task) throw new Error('createLocalTask: failed to load inserted task'); + return task; + } + + + export async function getLocalTask(db: Database.Database, taskId: number, opts?: { viewer?: Express.User }): Promise { + const viewerClause = opts?.viewer + ? buildVisibilityWhere(opts.viewer, 'lt', { spaceColumn: 'space_id' }) + : { clause: '1=1', params: [] as unknown[] }; + const row = db + .prepare(` + SELECT lt.*, + ${LOCAL_TASK_DISPLAY_SELECT} + FROM local_tasks lt + ${LOCAL_TASK_DISPLAY_JOIN} + WHERE lt.id = ? AND ${viewerClause.clause} + `) + .get(taskId, ...viewerClause.params) as LocalTaskRow | undefined; + if (!row) return null; + const task = rowToLocalTask(row); + task.latestJob = await jobsRepo.getLatestJobForIssue(db, localTaskRepoName(taskId), taskId); + // サブタスク情報を付与 + if (task.latestJob) { + const subJobs = await jobsRepo.getSubJobs(db, task.latestJob.id); + if (subJobs.length > 0) { + task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs); + task.subtaskCount = subJobs.length; + task.subtaskCompleted = subJobs.filter(j => + ['succeeded', 'failed', 'cancelled'].includes(j.status) + ).length; + } + } + return task; + } + + + export async function shareLocalTask(db: Database.Database, taskId: number): Promise { + const existing = await getLocalTask(db, taskId); + if (!existing) throw new Error(`Task ${taskId} not found`); + if (existing.shareToken) return existing.shareToken; + + const token = randomUUID(); + db.prepare( + `UPDATE local_tasks SET share_token = ?, shared_at = datetime('now'), updated_at = datetime('now') WHERE id = ?` + ).run(token, taskId); + return token; + } + + + export async function unshareLocalTask(db: Database.Database, taskId: number): Promise { + db.prepare( + `UPDATE local_tasks SET share_token = NULL, shared_at = NULL, updated_at = datetime('now') WHERE id = ?` + ).run(taskId); + } + + + /** + * Mission Brief: partial-replace update. Only fields explicitly provided + * are written; undefined leaves the field untouched. Passing all-empty + * strings is treated as "clear the brief" (NULL in storage). + * + * Returns the merged brief so callers can echo it back to the client + * without an extra read. + */ + export async function updateMissionBrief(db: Database.Database, taskId: number, patch: Partial): Promise { + return updateMissionBriefSync(db, taskId, patch); + } + + + /** Sync variant used by the engine's MissionBriefIO so it can be called + * from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync + * underneath anyway. */ + export function updateMissionBriefSync(db: Database.Database, taskId: number, patch: Partial): MissionBrief | null { + const row = db + .prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`) + .get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined; + const existing = parseMissionBrief(row?.mission_brief ?? null); + const next = {} as MissionBrief; + for (const field of MISSION_BRIEF_FIELDS) { + next[field] = patch[field] !== undefined ? patch[field] : existing?.[field] ?? ''; + } + const allEmpty = MISSION_BRIEF_FIELDS.every((field) => !next[field]); + const stored = allEmpty ? null : JSON.stringify(next); + + // Derive the task title from the agent's goal (no LLM call). Only when the + // goal value actually changed (agents re-send an unchanged brief across + // iterations — re-deriving every time would churn updated_at and flicker + // the title) and the user hasn't taken manual control (a user edit pins + // title_source='user' and is never overwritten). + const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? ''); + const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user') + ? buildTitleFromGoal(next.goal) + : ''; + + // Atomic: persist the brief and the derived title as one unit so a crash + // between them can't leave the title out of sync with the goal. + db.transaction(() => { + db.prepare( + `UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?` + ).run(stored, taskId); + if (derivedTitle) { + db.prepare( + `UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?` + ).run(derivedTitle, taskId); + } + })(); + return allEmpty ? null : next; + } + + + /** Sync read of the mission brief column. Used by MissionBriefIO.read() */ + export function getMissionBriefSync(db: Database.Database, taskId: number): MissionBrief | null { + const row = db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined; + return parseMissionBrief(row?.mission_brief ?? null); + } + + + /** + * Construct a MissionBriefIO bound to a specific local task. The engine + * uses this to thread mission brief read/write into the ToolContext + * without leaking the repository instance into tool code. + */ + export function makeMissionBriefIO(db: Database.Database, taskId: number): import('../../engine/tools/core.js').MissionBriefIO { + return { + read: () => getMissionBriefSync(db, taskId), + update: (patch) => updateMissionBriefSync(db, taskId, patch), + }; + } + + + /** + * Construct a TaskConversationIO bound to a single local task (comments) + + * this run's transcript path. Used by SearchTaskConversation / + * ReadTaskConversation; the binding is what scopes those tools to the current + * task so they can't read another task's logs. + */ + export function makeTaskConversationIO(db: Database.Database, taskId: number, transcriptPath?: string): import('../../engine/tools/core.js').TaskConversationIO { + return { + listComments: () => listLocalTaskComments(db, taskId), + transcriptPath, + }; + } + + + /** + * Transcript-only conversation IO for runs with no backing local_task + * (sub-tasks). Comments are always empty — sub-tasks don't write + * local_task comments — so only the transcript source is active. Mirrors + * makeTaskConversationIO but without a taskId. + */ + export function makeTranscriptOnlyConversationIO(transcriptPath: string | undefined): import('../../engine/tools/core.js').TaskConversationIO { + return { + listComments: async () => [], + transcriptPath, + }; + } + + + export async function getLocalTaskByShareToken(db: Database.Database, token: string): Promise { + const row = db + .prepare(` + SELECT lt.*, + ${LOCAL_TASK_DISPLAY_SELECT} + FROM local_tasks lt + ${LOCAL_TASK_DISPLAY_JOIN} + WHERE lt.share_token = ? + `) + .get(token) as LocalTaskRow | undefined; + if (!row) return null; + const task = rowToLocalTask(row); + task.latestJob = await jobsRepo.getLatestJobForIssue(db, localTaskRepoName(task.id), task.id); + if (task.latestJob) { + const subJobs = await jobsRepo.getSubJobs(db, task.latestJob.id); + if (subJobs.length > 0) { + task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs); + task.subtaskCount = subJobs.length; + task.subtaskCompleted = subJobs.filter(j => + ['succeeded', 'failed', 'cancelled'].includes(j.status) + ).length; + } + } + return task; + } + + + export async function listLocalTasks(db: Database.Database, filter?: { ownerId?: string; viewer?: Express.User }): Promise { + // 1. Single JOIN query: local_tasks LEFT JOIN jobs (latest per task via correlated subquery) + const conditions: string[] = []; + const queryParams: unknown[] = []; + if (filter?.ownerId) { + conditions.push('lt.owner_id = ?'); + queryParams.push(filter.ownerId); + } + if (filter?.viewer) { + const w = buildVisibilityWhere(filter.viewer, 'lt', { spaceColumn: 'space_id' }); + conditions.push(w.clause); + queryParams.push(...w.params); + } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const joinedRows = db + .prepare(` + SELECT + lt.*, + ${LOCAL_TASK_DISPLAY_SELECT}, + j.id AS job_id, + j.repo AS job_repo, + j.issue_number AS job_issue_number, + j.pr_number AS job_pr_number, + j.status AS job_status, + j.piece_name AS job_piece_name, + j.required_profile AS job_required_profile, + j.task_class AS job_task_class, + j.current_movement AS job_current_movement, + j.current_activity AS job_current_activity, + j.instruction AS job_instruction, + j.branch_name AS job_branch_name, + j.worktree_path AS job_worktree_path, + j.attempt AS job_attempt, + j.max_attempts AS job_max_attempts, + j.next_retry_at AS job_next_retry_at, + j.error_summary AS job_error_summary, + j.abort_reason AS job_abort_reason, + j.resume_movement AS job_resume_movement, + j.wait_reason AS job_wait_reason, + j.ask_count AS job_ask_count, + j.worker_id AS job_worker_id, + j.last_backend_id AS job_last_backend_id, + j.parent_job_id AS job_parent_job_id, + j.continued_from_job_id AS job_continued_from_job_id, + j.subtask_depth AS job_subtask_depth, + j.owner_id AS job_owner_id, + j.visibility AS job_visibility, + j.visibility_scope_org_id AS job_visibility_scope_org_id, + j.created_at AS job_created_at, + j.updated_at AS job_updated_at, + j.context_prompt_tokens AS job_context_prompt_tokens, + j.context_limit_tokens AS job_context_limit_tokens, + j.context_updated_at AS job_context_updated_at, + j.browser_session_profile_id AS job_browser_session_profile_id, + j.task_kind AS job_task_kind, + j.payload AS job_payload + FROM local_tasks lt + ${LOCAL_TASK_DISPLAY_JOIN} + LEFT JOIN jobs j ON j.id = ( + SELECT j2.id FROM jobs j2 + WHERE j2.repo = 'local/task-' || lt.id + AND j2.issue_number = lt.id + ORDER BY j2.created_at DESC, j2.rowid DESC + LIMIT 1 + ) + ${whereClause} + ORDER BY lt.updated_at DESC, lt.id DESC + `) + .all(...queryParams) as Array; + + // Build tasks with latestJob from joined data + const tasks: LocalTask[] = []; + const jobIds: string[] = []; + + for (const row of joinedRows) { + const task = rowToLocalTask(row); + if (row.job_id) { + task.latestJob = rowToJob({ + id: row.job_id, + repo: row.job_repo!, + issue_number: row.job_issue_number!, + pr_number: row.job_pr_number ?? null, + status: row.job_status!, + piece_name: row.job_piece_name!, + current_movement: row.job_current_movement ?? null, + current_activity: row.job_current_activity ?? null, + instruction: row.job_instruction!, + branch_name: row.job_branch_name ?? null, + worktree_path: row.job_worktree_path ?? null, + attempt: row.job_attempt!, + max_attempts: row.job_max_attempts!, + next_retry_at: row.job_next_retry_at ?? null, + error_summary: row.job_error_summary ?? null, + abort_reason: row.job_abort_reason ?? null, + resume_movement: row.job_resume_movement ?? null, + wait_reason: row.job_wait_reason ?? null, + ask_count: row.job_ask_count!, + worker_id: row.job_worker_id ?? null, + last_backend_id: row.job_last_backend_id ?? null, + parent_job_id: row.job_parent_job_id ?? null, + continued_from_job_id: row.job_continued_from_job_id ?? null, + subtask_depth: row.job_subtask_depth ?? 0, + required_profile: row.job_required_profile!, + task_class: row.job_task_class!, + owner_id: row.job_owner_id ?? null, + visibility: row.job_visibility ?? null, + visibility_scope_org_id: row.job_visibility_scope_org_id ?? null, + context_prompt_tokens: row.job_context_prompt_tokens ?? null, + context_limit_tokens: row.job_context_limit_tokens ?? null, + context_updated_at: row.job_context_updated_at ?? null, + browser_session_profile_id: row.job_browser_session_profile_id ?? null, + task_kind: row.job_task_kind ?? 'agent', + payload: row.job_payload ?? null, + // この join は latestJob 表示用。スペース解決は local_tasks.space_id を使うため + // ここでは job の space_id / runtime_dir を選択しておらず null で足りる。 + space_id: null, + runtime_dir: null, + created_at: row.job_created_at!, + updated_at: row.job_updated_at!, + }); + jobIds.push(row.job_id); + } else { + task.latestJob = null; + } + tasks.push(task); + } + + // 2. Single query for all sub-jobs + if (jobIds.length > 0) { + const placeholders = jobIds.map(() => '?').join(', '); + const subJobRows = db + .prepare(` + SELECT * FROM ( + SELECT j.*, ROW_NUMBER() OVER ( + PARTITION BY j.parent_job_id, j.issue_number + ORDER BY j.created_at DESC, j.rowid DESC + ) AS rn + FROM jobs j + WHERE j.parent_job_id IN (${placeholders}) + ) WHERE rn = 1 + ORDER BY parent_job_id, issue_number ASC + `) + .all(...jobIds) as JobRow[]; + + // Group sub-jobs by parent_job_id + const subJobsByParent = new Map(); + for (const row of subJobRows) { + const parentId = row.parent_job_id!; + if (!subJobsByParent.has(parentId)) { + subJobsByParent.set(parentId, []); + } + subJobsByParent.get(parentId)!.push(rowToJob(row)); + } + + // 3. Attach subtask info to tasks + for (const task of tasks) { + if (task.latestJob && subJobsByParent.has(task.latestJob.id)) { + const subJobs = subJobsByParent.get(task.latestJob.id)!; + task.subtasks = await jobsRepo.buildSubtaskInfos(db, subJobs); + task.subtaskCount = subJobs.length; + task.subtaskCompleted = subJobs.filter(j => + ['succeeded', 'failed', 'cancelled'].includes(j.status) + ).length; + } + } + } + + return tasks; + } + + + export async function updateLocalTask(db: Database.Database, taskId: number, updates: Partial>): Promise { + const setClauses: string[] = ["updated_at = datetime('now')"]; + const params: Record = { taskId }; + const fieldMap: Record = { + title: 'title', + titleSource: 'title_source', + body: 'body', + pieceName: 'piece_name', + profile: 'profile', + outputFormat: 'output_format', + askPolicy: 'ask_policy', + priority: 'priority', + state: 'state', + workspacePath: 'workspace_path', + runtimeDir: 'runtime_dir', + visibility: 'visibility', + visibilityScopeOrgId: 'visibility_scope_org_id', + }; + + for (const [jsKey, dbCol] of Object.entries(fieldMap)) { + const val = (updates as Record)[jsKey]; + if (val !== undefined) { + setClauses.push(`${dbCol} = @${jsKey}`); + params[jsKey] = val; + } + } + + if (setClauses.length === 1) return; + + db + .prepare(`UPDATE local_tasks SET ${setClauses.join(', ')} WHERE id = @taskId`) + .run(params); + } + + + export async function updateFeedback(db: Database.Database, taskId: number, feedback: { + rating: 'good' | 'bad'; + tags: string[]; + comment: string | null; + }): Promise { + db + .prepare(` + UPDATE local_tasks + SET feedback_rating = @rating, + feedback_tags = @tags, + feedback_comment = @comment, + feedback_at = datetime('now'), + updated_at = datetime('now') + WHERE id = @taskId + `) + .run({ + taskId, + rating: feedback.rating, + tags: JSON.stringify(feedback.tags), + comment: feedback.comment, + }); + } + + + export async function addLocalTaskComment(db: Database.Database, taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise { + const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null; + const result = db + .prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)') + .run(taskId, author, kind, body, attachmentsJson); + db + .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?") + .run(taskId); + const row = db + .prepare('SELECT * FROM local_task_comments WHERE id = ?') + .get(Number(result.lastInsertRowid)) as LocalTaskCommentRow | undefined; + if (!row) throw new Error('addLocalTaskComment: failed to load inserted comment'); + taskSearchRepo.indexTaskComment(db, row.id, taskId, author, kind, row.created_at, body, attachmentsJson); + return rowToLocalTaskComment(row); + } + + + export async function listLocalTaskComments(db: Database.Database, taskId: number): Promise { + const rows = db + .prepare('SELECT * FROM local_task_comments WHERE task_id = ? ORDER BY created_at ASC, id ASC') + .all(taskId) as LocalTaskCommentRow[]; + return rows.map(rowToLocalTaskComment); + } + + + export async function getUninjectedComments(db: Database.Database, taskId: number, sinceId: number = 0): Promise { + const rows = db + .prepare( + `SELECT * FROM local_task_comments + WHERE task_id = ? AND id > ? AND author = 'user' AND injected_at IS NULL + ORDER BY id ASC` + ) + .all(taskId, sinceId) as LocalTaskCommentRow[]; + return rows.map(rowToLocalTaskComment); + } + + + export function markCommentsInjected(db: Database.Database, commentIds: number[]): void { + if (commentIds.length === 0) return; + const placeholders = commentIds.map(() => '?').join(','); + db + .prepare(`UPDATE local_task_comments SET injected_at = datetime('now') WHERE id IN (${placeholders})`) + .run(...commentIds); + } + + + /** + * Latest agent-authored "result" or "ask" comment for a task. Used by the + * piece handoff feature to surface the previous job's terminal output as + * context to a continuation job's LLM. Returns null when none exist + * (e.g., task has not yet completed any job). + */ + export async function getLatestResultComment(db: Database.Database, taskId: number): Promise<{ body: string; kind: string; createdAt: string } | null> { + const row = db + .prepare( + `SELECT body, kind, created_at + FROM local_task_comments + WHERE task_id = ? AND author = 'agent' AND kind IN ('result', 'ask') + ORDER BY created_at DESC + LIMIT 1` + ) + .get(taskId) as { body: string; kind: string; created_at: string } | undefined; + return row ? { body: row.body, kind: row.kind, createdAt: row.created_at } : null; + } + + + export async function deleteLocalTask(db: Database.Database, taskId: number, worktreeDir?: string): Promise { + const repoName = localTaskRepoName(taskId); + + // タスク存在確認 & workspace_path / space_id / mode / runtime_dir を取得(削除前に必要) + const taskRow = db + .prepare('SELECT workspace_path, space_id, workspace_mode, runtime_dir FROM local_tasks WHERE id = ?') + .get(taskId) as { + workspace_path: string | null; + space_id: string | null; + workspace_mode: string | null; + runtime_dir: string | null; + } | undefined; + if (!taskRow) { + throw new Error(`deleteLocalTask: task ${taskId} not found`); + } + + // running/dispatching なジョブがある場合は削除を拒否 + const activeJob = db + .prepare("SELECT id FROM jobs WHERE repo = ? AND status IN ('running', 'dispatching') LIMIT 1") + .get(repoName) as { id: string } | undefined; + if (activeJob) { + throw new Error(`deleteLocalTask: task ${taskId} has an active job (${activeJob.id})`); + } + + // DB 操作をトランザクションで実行 + const deleteTransaction = db.transaction(() => { + db.prepare('DELETE FROM issue_locks WHERE repo = ?').run(repoName); + db.prepare('DELETE FROM jobs WHERE repo = ?').run(repoName); + db.prepare('DELETE FROM task_comment_index WHERE task_id = ?').run(taskId); + db.prepare('DELETE FROM local_tasks WHERE id = ?').run(taskId); + }); + deleteTransaction(); + + // ワークスペース削除(DB トランザクション外 — ロールバック不可のため)。 + // persistent スペースタスクの workspace_path は **スペース共有ツリー** + // (`{worktreeDir}/space/{spaceId}/files`) で、同じスペースの全会話が共有する。 + // 1 会話の削除でこれを rm すると他会話の output/input まで巻き添えで消える(データ消失) + // ため、共有ツリーは削除しない(DB 行のみ削除)。ephemeral/local の per-task + // ワークスペースは従来どおり削除する。会話固有の runtime_dir + // (`space/{spaceId}/runs/{taskId}` = logs/checklists/raw) は per-会話なので掃除する。 + + // パスヘルパー: normalize(末尾区切り除去)/ 実体 realpath / 末尾セグメント一致。 + const norm = (p: string): string => normalize(p).replace(/[/\\]+$/, ''); + const realOf = (p: string): string => { + const n = norm(p); + try { return existsSync(n) ? realpathSync(n) : n; } catch { return n; } + }; + const hasTail = (path: string, tail: string[]): boolean => { + const segs = path.split(/[/\\]+/); + return segs.length >= tail.length && tail.every((t, i) => segs[segs.length - tail.length + i] === t); + }; + + // 削除は **ホワイトリスト + worktreeDir アンカー** 方式。「共有ツリー形を blacklist して + // 例外を追う」と symlink/normalization のたびに抜け道が増えるため、逆に **設定された + // worktreeDir 配下に root され、かつシステムが作る per-task 形** のものだけを消す。 + // 判定は logical と realpath の **両方** で行い、どちらかが worktree 外/別実体を指す + // symlink なら消さない(共有ツリーや worktree 外データを巻き込まない)。 + // worktreeDir が無い経路(legacy/一部テスト)は安全側に倒して fs 削除を一切しない。 + if (worktreeDir) { + const root = realOf(worktreeDir); + const rooted = (real: string): boolean => real === root || real.startsWith(root + sep); + + // workspace_path: per-task は ephemeral/{taskId} または旧 local/{taskId} のみ。共有ツリー + // (space/{id}/files)・worktree 外・symlink リダイレクトは保持。 + if (taskRow.workspace_path) { + const wsReal = realOf(taskRow.workspace_path); + const perTaskShape = (s: string): boolean => + hasTail(s, ['ephemeral', String(taskId)]) || hasTail(s, ['local', String(taskId)]); + if (rooted(wsReal) && perTaskShape(wsReal) && perTaskShape(norm(taskRow.workspace_path)) && existsSync(wsReal)) { + rmSync(wsReal, { recursive: true, force: true }); + } else { + logger.info(`Repository: deleteLocalTask kept non-per-task workspace ${taskRow.workspace_path} (task ${taskId})`); + } + } + + // runtime_dir: 会話固有 (space/<任意>/runs/{taskId})。worktree 配下に root され、logical/real + // 両方が runs/{taskId} 形のときだけ掃除(共有 files を指す symlink は real 形が一致せず除外)。 + if (taskRow.runtime_dir) { + const rtReal = realOf(taskRow.runtime_dir); + const runsShape = (s: string): boolean => + hasTail(s, ['runs', String(taskId)]) && s.split(/[/\\]+/).slice(-4)[0] === 'space'; + if (rooted(rtReal) && runsShape(rtReal) && runsShape(norm(taskRow.runtime_dir)) && existsSync(rtReal)) { + rmSync(rtReal, { recursive: true, force: true }); + } else { + logger.info(`Repository: deleteLocalTask kept runtime_dir ${taskRow.runtime_dir} (task ${taskId})`); + } + } + } + + logger.info(`Repository: deleted local task ${taskId}`); + } diff --git a/src/db/repositories/notifications.ts b/src/db/repositories/notifications.ts new file mode 100644 index 0000000..9fb82f5 --- /dev/null +++ b/src/db/repositories/notifications.ts @@ -0,0 +1,321 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// notifications repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + +// ── Browser Notifications V2 (Web Push) ────────────────────────────── +// Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md + +export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; + + +export interface PushSubscriptionRecord { + id: string; + userId: string; + endpoint: string; + p256dh: string; + auth: string; + userAgent: string | null; + vapidKeyId: string; + createdAt: string; + lastSuccessAt: string | null; + lastFailureAt: string | null; + failureCount: number; +} + + +export interface UpsertPushSubscriptionInput { + userId: string; + endpoint: string; + p256dh: string; + auth: string; + userAgent?: string | null; + vapidKeyId: string; +} + + +export interface NotificationPrefs { + userId: string; + enabled: boolean; + events: Record; + includeDetails: boolean; + v1Migrated: boolean; + updatedAt: string; +} + + +export type NotificationPrefsUpdate = Partial>; + + + // ── Browser Notifications V2: push_subscriptions ──────────────────── + // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md + + /** + * Insert or, on endpoint collision, transfer ownership to the new user. + * endpoint is globally UNIQUE: the same browser logging in as a different + * user re-uses the same push service URL, so we move it rather than fail. + */ + export function upsertPushSubscription(db: Database.Database, input: UpsertPushSubscriptionInput): { id: string } { + const existing = db + .prepare('SELECT id FROM push_subscriptions WHERE endpoint = ?') + .get(input.endpoint) as { id: string } | undefined; + if (existing) { + db + .prepare( + `UPDATE push_subscriptions + SET user_id = ?, p256dh = ?, auth = ?, user_agent = ?, + vapid_key_id = ?, last_success_at = NULL, + last_failure_at = NULL, failure_count = 0 + WHERE id = ?`, + ) + .run( + input.userId, + input.p256dh, + input.auth, + input.userAgent ?? null, + input.vapidKeyId, + existing.id, + ); + return { id: existing.id }; + } + const id = randomUUID(); + db + .prepare( + `INSERT INTO push_subscriptions + (id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + input.userId, + input.endpoint, + input.p256dh, + input.auth, + input.userAgent ?? null, + input.vapidKeyId, + ); + return { id }; + } + + + export function listPushSubscriptionsForUser(db: Database.Database, userId: string): PushSubscriptionRecord[] { + const rows = db + .prepare( + `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, + created_at, last_success_at, last_failure_at, failure_count + FROM push_subscriptions + WHERE user_id = ? + ORDER BY created_at ASC`, + ) + .all(userId) as Array<{ + id: string; + user_id: string; + endpoint: string; + p256dh: string; + auth: string; + user_agent: string | null; + vapid_key_id: string; + created_at: string; + last_success_at: string | null; + last_failure_at: string | null; + failure_count: number; + }>; + return rows.map(rowToPushSubscription); + } + + + export function getPushSubscriptionById(db: Database.Database, id: string): PushSubscriptionRecord | null { + const row = db + .prepare( + `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, + created_at, last_success_at, last_failure_at, failure_count + FROM push_subscriptions WHERE id = ?`, + ) + .get(id) as Parameters[0] | undefined; + return row ? rowToPushSubscription(row) : null; + } + + + export function deletePushSubscription(db: Database.Database, id: string): void { + db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(id); + } + + + export function markPushSubscriptionSuccess(db: Database.Database, id: string): void { + db + .prepare( + `UPDATE push_subscriptions + SET last_success_at = datetime('now'), failure_count = 0 + WHERE id = ?`, + ) + .run(id); + } + + + export function markPushSubscriptionFailure(db: Database.Database, id: string): void { + db + .prepare( + `UPDATE push_subscriptions + SET last_failure_at = datetime('now'), + failure_count = failure_count + 1 + WHERE id = ?`, + ) + .run(id); + } + + + // ── Browser Notifications V2: user_notification_prefs ──────────────── + + /** + * Read per-user prefs; create row with defaults if none exists. + * Defaults: enabled=true, all events on, include_details=false, v1_migrated=false. + */ + export function getUserNotificationPrefs(db: Database.Database, userId: string): NotificationPrefs { + const row = db + .prepare( + `SELECT user_id, enabled, event_running, event_succeeded, + event_failed, event_waiting_human, include_details, + v1_migrated, updated_at + FROM user_notification_prefs WHERE user_id = ?`, + ) + .get(userId) as + | { + user_id: string; + enabled: number; + event_running: number; + event_succeeded: number; + event_failed: number; + event_waiting_human: number; + include_details: number; + v1_migrated: number; + updated_at: string; + } + | undefined; + if (!row) { + // Lazily create the default row so subsequent reads/updates are + // a simple UPDATE rather than a conditional insert. + db + .prepare( + `INSERT INTO user_notification_prefs (user_id) VALUES (?) + ON CONFLICT(user_id) DO NOTHING`, + ) + .run(userId); + return { + userId, + enabled: true, + events: { running: true, succeeded: true, failed: true, waiting_human: true }, + includeDetails: false, + v1Migrated: false, + updatedAt: new Date().toISOString(), + }; + } + return { + userId: row.user_id, + enabled: row.enabled !== 0, + events: { + running: row.event_running !== 0, + succeeded: row.event_succeeded !== 0, + failed: row.event_failed !== 0, + waiting_human: row.event_waiting_human !== 0, + }, + includeDetails: row.include_details !== 0, + v1Migrated: row.v1_migrated !== 0, + updatedAt: row.updated_at, + }; + } + + + export function upsertUserNotificationPrefs(db: Database.Database, userId: string, update: NotificationPrefsUpdate): void { + // Ensure a row exists first (lazy default creation matches getUserNotificationPrefs). + db + .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) + .run(userId); + const sets: string[] = []; + const params: Array = []; + if (update.enabled !== undefined) { + sets.push('enabled = ?'); + params.push(update.enabled ? 1 : 0); + } + if (update.events) { + if (update.events.running !== undefined) { + sets.push('event_running = ?'); + params.push(update.events.running ? 1 : 0); + } + if (update.events.succeeded !== undefined) { + sets.push('event_succeeded = ?'); + params.push(update.events.succeeded ? 1 : 0); + } + if (update.events.failed !== undefined) { + sets.push('event_failed = ?'); + params.push(update.events.failed ? 1 : 0); + } + if (update.events.waiting_human !== undefined) { + sets.push('event_waiting_human = ?'); + params.push(update.events.waiting_human ? 1 : 0); + } + } + if (update.includeDetails !== undefined) { + sets.push('include_details = ?'); + params.push(update.includeDetails ? 1 : 0); + } + if (update.v1Migrated !== undefined) { + sets.push('v1_migrated = ?'); + params.push(update.v1Migrated ? 1 : 0); + } + if (sets.length === 0) return; + sets.push("updated_at = datetime('now')"); + params.push(userId); + db + .prepare(`UPDATE user_notification_prefs SET ${sets.join(', ')} WHERE user_id = ?`) + .run(...params); + } + + + /** + * One-time V1 (localStorage) → V2 (server) preferences migration marker. + * Returns true if this call performed the migration mark (caller should + * then apply the localStorage values via upsertUserNotificationPrefs). + * Returns false if already migrated (caller should treat as 409 conflict). + */ + export function markV1MigrationComplete(db: Database.Database, userId: string): boolean { + db + .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) + .run(userId); + const result = db + .prepare( + `UPDATE user_notification_prefs + SET v1_migrated = 1, updated_at = datetime('now') + WHERE user_id = ? AND v1_migrated = 0`, + ) + .run(userId); + return result.changes > 0; + } + + +export function rowToPushSubscription(row: { + id: string; + user_id: string; + endpoint: string; + p256dh: string; + auth: string; + user_agent: string | null; + vapid_key_id: string; + created_at: string; + last_success_at: string | null; + last_failure_at: string | null; + failure_count: number; +}): PushSubscriptionRecord { + return { + id: row.id, + userId: row.user_id, + endpoint: row.endpoint, + p256dh: row.p256dh, + auth: row.auth, + userAgent: row.user_agent, + vapidKeyId: row.vapid_key_id, + createdAt: row.created_at, + lastSuccessAt: row.last_success_at, + lastFailureAt: row.last_failure_at, + failureCount: row.failure_count, + }; +} diff --git a/src/db/repositories/provenance.ts b/src/db/repositories/provenance.ts new file mode 100644 index 0000000..24e37f2 --- /dev/null +++ b/src/db/repositories/provenance.ts @@ -0,0 +1,178 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// provenance repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { escapeLike } from './shared.js'; + +/** DB row shape for workspace_file_provenance. */ +export interface WorkspaceFileProvenanceRow { + space_id: string | null; + workspace_path: string; + rel_path: string; + created_by_task_id: number | null; + created_by_job_id: string | null; + created_by_piece: string | null; + created_by_movement: string | null; + source_kind: string; + first_seen_at: string | null; + last_modified_by_task_id: number | null; + last_modified_by_job_id: string | null; + last_modified_at: string | null; + checksum: string | null; + note: string | null; +} + + +export function rowToFileProvenance( + row: WorkspaceFileProvenanceRow, +): import('../../engine/tools/core.js').FileProvenanceRecord { + return { + relPath: row.rel_path, + sourceKind: (row.source_kind as import('../../engine/tools/core.js').FileProvenanceSourceKind) ?? 'unknown', + createdByTaskId: row.created_by_task_id ?? null, + createdByJobId: row.created_by_job_id ?? null, + createdByPiece: row.created_by_piece ?? null, + createdByMovement: row.created_by_movement ?? null, + firstSeenAt: row.first_seen_at ?? null, + lastModifiedByTaskId: row.last_modified_by_task_id ?? null, + lastModifiedByJobId: row.last_modified_by_job_id ?? null, + lastModifiedAt: row.last_modified_at ?? null, + checksum: row.checksum ?? null, + note: row.note ?? null, + }; +} + + + // ── Workspace file provenance ──────────────────────────────────────────── + // Every read/write is scoped by workspace_path (the shared files-tree root), + // which is the isolation boundary: a query for workspace A can never surface + // workspace B's rows (and thus never another user's task lineage in a shared + // space). schema.sql / migrate.ts / initSchema keep the table in triple sync. + + /** + * Upsert a provenance row for one workspace file. + * - event 'create' with no existing row → inserts the creator identity. + * - existing row (any event) → updates only the last-modified fields, so the + * original creator + source_kind are preserved (Edit / overwrite semantics). + * - event 'modify' with no existing row → backfills a row for a file that + * pre-dates the ledger (source_kind=imported_existing, creator NULL). + */ + export function recordProvenance(db: Database.Database, params: { + workspacePath: string; + relPath: string; + event: 'create' | 'modify'; + sourceKind?: import('../../engine/tools/core.js').FileProvenanceSourceKind; + spaceId?: string | null; + taskId?: number | null; + jobId?: string | null; + piece?: string | null; + movement?: string | null; + checksum?: string | null; + note?: string | null; + }): void { + const now = new Date().toISOString(); + const taskId = params.taskId ?? null; + const jobId = params.jobId ?? null; + const checksum = params.checksum ?? null; + const existing = db + .prepare('SELECT id FROM workspace_file_provenance WHERE workspace_path = ? AND rel_path = ?') + .get(params.workspacePath, params.relPath) as { id: number } | undefined; + + if (existing) { + // Preserve creator + source_kind; only advance the last-modified pointer. + db + .prepare( + `UPDATE workspace_file_provenance + SET last_modified_by_task_id = ?, last_modified_by_job_id = ?, + last_modified_at = ?, checksum = COALESCE(?, checksum) + WHERE id = ?`, + ) + .run(taskId, jobId, now, checksum, existing.id); + return; + } + + // No row yet. A create event knows the creator; a modify event means the + // file pre-dates the ledger → backfill with an unknown creator. + const sourceKind = + params.event === 'create' ? params.sourceKind ?? 'unknown' : 'imported_existing'; + const createdTask = params.event === 'create' ? taskId : null; + const createdJob = params.event === 'create' ? jobId : null; + const createdPiece = params.event === 'create' ? params.piece ?? null : null; + const createdMovement = params.event === 'create' ? params.movement ?? null : null; + db + .prepare( + `INSERT INTO workspace_file_provenance + (space_id, workspace_path, rel_path, created_by_task_id, created_by_job_id, + created_by_piece, created_by_movement, source_kind, first_seen_at, + last_modified_by_task_id, last_modified_by_job_id, last_modified_at, checksum, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + params.spaceId ?? null, + params.workspacePath, + params.relPath, + createdTask, + createdJob, + createdPiece, + createdMovement, + sourceKind, + now, + taskId, + jobId, + now, + checksum, + params.note ?? null, + ); + } + + + export function getProvenance(db: Database.Database, workspacePath: string, relPath: string): import('../../engine/tools/core.js').FileProvenanceRecord | null { + const row = db + .prepare('SELECT * FROM workspace_file_provenance WHERE workspace_path = ? AND rel_path = ?') + .get(workspacePath, relPath) as WorkspaceFileProvenanceRow | undefined; + return row ? rowToFileProvenance(row) : null; + } + + + export function listProvenance(db: Database.Database, workspacePath: string, filters?: import('../../engine/tools/core.js').FileProvenanceListFilters): import('../../engine/tools/core.js').FileProvenanceRecord[] { + const clauses = ['workspace_path = ?']; + const args: unknown[] = [workspacePath]; + if (filters?.pathPrefix) { + clauses.push('rel_path LIKE ? ESCAPE \'\\\''); + args.push(escapeLike(filters.pathPrefix) + '%'); + } + if (filters?.sourceKind) { + clauses.push('source_kind = ?'); + args.push(filters.sourceKind); + } + if (typeof filters?.createdByTaskId === 'number') { + clauses.push('created_by_task_id = ?'); + args.push(filters.createdByTaskId); + } + if (typeof filters?.lastModifiedByTaskId === 'number') { + clauses.push('last_modified_by_task_id = ?'); + args.push(filters.lastModifiedByTaskId); + } + if (filters?.includeUnknown === false) { + clauses.push("source_kind NOT IN ('unknown', 'imported_existing')"); + } + // Cap hard at 500 rows regardless of caller input (bounded output). + const limit = Math.min(Math.max(filters?.limit ?? 200, 1), 500); + const rows = db + .prepare( + `SELECT * FROM workspace_file_provenance + WHERE ${clauses.join(' AND ')} + ORDER BY rel_path ASC + LIMIT ?`, + ) + .all(...args, limit) as WorkspaceFileProvenanceRow[]; + return rows.map(rowToFileProvenance); + } + + + /** Construct a workspace-bound read IO for GetFileProvenance / ListWorkspaceFiles. */ + export function makeFileProvenanceIO(db: Database.Database, workspacePath: string): import('../../engine/tools/core.js').FileProvenanceIO { + return { + get: (relPath) => getProvenance(db, workspacePath, relPath), + list: (filters) => listProvenance(db, workspacePath, filters), + }; + } diff --git a/src/db/repositories/reflection.ts b/src/db/repositories/reflection.ts new file mode 100644 index 0000000..67bc160 --- /dev/null +++ b/src/db/repositories/reflection.ts @@ -0,0 +1,143 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// reflection repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; + + // ── Reflection piece-edit cooldown ────────────────────────────────────────── + + /** + * Records that the reflection pipeline wrote a new version of pieceName for + * userId. snapshotId ties the edit back to the snapshot that triggered it. + */ + export function recordPieceEdit(db: Database.Database, userId: string, pieceName: string, snapshotId: string): void { + db.prepare( + `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) + VALUES (?, ?, ?, ?)` + ).run(userId, pieceName, snapshotId, Date.now()); + } + + + /** + * Returns the number of piece edits for (userId, pieceName) that occurred + * within the last sinceMs milliseconds. Used by the cooldown gate in + * piece-writer.ts to prevent over-editing the same piece. + */ + export function countRecentPieceEdits(db: Database.Database, userId: string, pieceName: string, sinceMs: number): number { + return (db.prepare( + `SELECT COUNT(*) AS c FROM reflection_piece_edits + WHERE user_id = ? AND piece_name = ? AND created_at > ?` + ).get(userId, pieceName, Date.now() - sinceMs) as { c: number }).c; + } + + + // ── Reflection metrics ─────────────────────────────────────────────────────── + + /** + * Insert one row into reflection_metrics, optionally bundling a + * reflection_piece_edits row in the same transaction. + * + * When pieceEdit is supplied the two inserts are wrapped in a single + * db.transaction() so the tables stay consistent even if the process + * crashes between them. + */ + export function recordReflectionRun(db: Database.Database, metric: { + reflection_job_id: string; + original_job_id: string | null; + user_id: string; + piece_name: string | null; + outcome: 'applied' | 'partial' | 'abstained' | 'rejected' | 'failed'; + memory_changes: number; + piece_edited: 0 | 1; + tokens_in: number; + tokens_out: number; + duration_ms: number; + }, pieceEdit?: { pieceName: string; snapshotId: string }): void { + const now = Date.now(); + const insertMetric = db.prepare(` + INSERT INTO reflection_metrics + (reflection_job_id, original_job_id, user_id, piece_name, outcome, + memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at) + VALUES + (@reflection_job_id, @original_job_id, @user_id, @piece_name, @outcome, + @memory_changes, @piece_edited, @tokens_in, @tokens_out, @duration_ms, @created_at) + `); + + if (pieceEdit) { + const insertEdit = db.prepare(` + INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) + VALUES (?, ?, ?, ?) + `); + db.transaction(() => { + insertMetric.run({ ...metric, created_at: now }); + insertEdit.run(metric.user_id, pieceEdit.pieceName, pieceEdit.snapshotId, now); + })(); + } else { + insertMetric.run({ ...metric, created_at: now }); + } + } + + + /** + * Convenience alias for callers that don't need the bundled pieceEdit path. + */ + export function recordReflectionMetric(db: Database.Database, row: { + reflection_job_id: string; + original_job_id: string | null; + user_id: string; + piece_name: string | null; + outcome: 'applied' | 'partial' | 'abstained' | 'rejected' | 'failed'; + memory_changes: number; + piece_edited: 0 | 1; + tokens_in: number; + tokens_out: number; + duration_ms: number; + }): void { + recordReflectionRun(db, row); + } + + + /** + * Aggregate reflection metrics for a user since sinceMs (epoch ms). + * Returns counts per outcome and totals for tokens + piece edits. + */ + export function aggregateReflectionMetrics(db: Database.Database, userId: string, sinceMs: number): { + applied: number; + partial: number; + abstained: number; + rejected: number; + failed: number; + tokensIn: number; + tokensOut: number; + pieceEdits: number; + totalRuns: number; + } { + const rows = db + .prepare( + `SELECT outcome, COUNT(*) AS cnt, + SUM(tokens_in) AS ti, SUM(tokens_out) AS to_, + SUM(piece_edited) AS pe + FROM reflection_metrics + WHERE user_id = ? AND created_at >= ? + GROUP BY outcome`, + ) + .all(userId, sinceMs) as Array<{ + outcome: string; + cnt: number; + ti: number; + to_: number; + pe: number; + }>; + + const result = { + applied: 0, partial: 0, abstained: 0, rejected: 0, failed: 0, + tokensIn: 0, tokensOut: 0, pieceEdits: 0, totalRuns: 0, + }; + for (const r of rows) { + const o = r.outcome as keyof Pick; + if (o in result) (result as Record)[o] = r.cnt; + result.tokensIn += r.ti ?? 0; + result.tokensOut += r.to_ ?? 0; + result.pieceEdits += r.pe ?? 0; + result.totalRuns += r.cnt; + } + return result; + } diff --git a/src/db/repositories/scheduled-tasks.ts b/src/db/repositories/scheduled-tasks.ts new file mode 100644 index 0000000..f587315 --- /dev/null +++ b/src/db/repositories/scheduled-tasks.ts @@ -0,0 +1,305 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// scheduled-tasks repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { buildVisibilityWhere } from '../../bridge/visibility.js'; +import { utc } from './shared.js'; + +export const SCHEDULED_TASK_DISPLAY_SELECT = ` + u.name AS owner_name, + COALESCE( + (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = st.visibility_scope_org_id), + (SELECT name FROM local_orgs WHERE id = st.visibility_scope_org_id) + ) AS visibility_scope_org_name +`.trim(); + +export const SCHEDULED_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = st.owner_id`; + + +export type ScheduledTaskKind = 'agent' | 'script'; + + +export interface ScheduledTask { + id: number; + title: string | null; + body: string; + pieceName: string; + profile: string; + outputFormat: string; + cronExpression: string; + nextRunAt: string; + lastRunAt: string | null; + lastJobId: string | null; + isActive: boolean; + ownerId: string | null; + ownerName?: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + visibilityScopeOrgName?: string | null; + browserSessionProfileId?: number | null; + taskKind: ScheduledTaskKind; + scriptName: string | null; + scriptParams: string | null; // JSON-encoded object or null + /** 所属スペース。生成される local_task に継承される。null = owner 個人スペース。 */ + spaceId: string | null; + createdAt: string; + updatedAt: string; +} + + +export interface CreateScheduledTaskParams { + title?: string | null; + body: string; + pieceName?: string; + profile?: string; + outputFormat?: string; + cronExpression: string; + nextRunAt: string; + ownerId?: string | null; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + browserSessionProfileId?: number | null; + taskKind?: ScheduledTaskKind; + scriptName?: string | null; + scriptParams?: string | null; + spaceId?: string | null; +} + + +export interface UpdateScheduledTaskParams { + title?: string; + body?: string; + pieceName?: string; + profile?: string; + outputFormat?: string; + cronExpression?: string; + nextRunAt?: string; + lastRunAt?: string; + lastJobId?: string; + isActive?: boolean; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + browserSessionProfileId?: number | null; + taskKind?: ScheduledTaskKind; + scriptName?: string | null; + scriptParams?: string | null; +} + + + // ── Scheduled Tasks ────────────────────────────────────────── + + export function mapScheduledTask(row: any): ScheduledTask { + const rawVisibility = row.visibility; + const visibility: ScheduledTask['visibility'] = + rawVisibility === 'org' || rawVisibility === 'public' ? rawVisibility : 'private'; + return { + id: row.id, + title: row.title, + body: row.body, + pieceName: row.piece_name, + profile: row.profile, + outputFormat: row.output_format, + cronExpression: row.cron_expression, + nextRunAt: utc(row.next_run_at), + lastRunAt: utc(row.last_run_at), + lastJobId: row.last_job_id, + isActive: row.is_active === 1, + ownerId: row.owner_id ?? null, + ownerName: row.owner_name ?? null, + visibility, + visibilityScopeOrgId: row.visibility_scope_org_id ?? null, + visibilityScopeOrgName: row.visibility_scope_org_name ?? null, + browserSessionProfileId: row.browser_session_profile_id ?? null, + taskKind: row.task_kind === 'script' ? 'script' : 'agent', + scriptName: row.script_name ?? null, + scriptParams: row.script_params ?? null, + spaceId: row.space_id ?? null, + createdAt: utc(row.created_at), + updatedAt: utc(row.updated_at), + }; + } + + + export async function createScheduledTask(db: Database.Database, params: CreateScheduledTaskParams): Promise { + const result = db + .prepare( + `INSERT INTO scheduled_tasks (title, body, piece_name, profile, output_format, cron_expression, next_run_at, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, script_name, script_params, space_id) + VALUES (@title, @body, @pieceName, @profile, @outputFormat, @cronExpression, @nextRunAt, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @scriptName, @scriptParams, @spaceId)` + ) + .run({ + title: params.title ?? null, + body: params.body, + pieceName: params.pieceName ?? 'auto', + profile: params.profile ?? 'auto', + outputFormat: params.outputFormat ?? 'markdown', + cronExpression: params.cronExpression, + nextRunAt: params.nextRunAt, + ownerId: params.ownerId ?? null, + visibility: params.visibility ?? 'private', + visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, + browserSessionProfileId: params.browserSessionProfileId ?? null, + taskKind: params.taskKind ?? 'agent', + scriptName: params.scriptName ?? null, + scriptParams: params.scriptParams ?? null, + spaceId: params.spaceId ?? null, + }); + const task = getScheduledTaskSync(db, Number(result.lastInsertRowid)); + if (!task) throw new Error('createScheduledTask: failed to load inserted task'); + return task; + } + + + export function getScheduledTaskSync(db: Database.Database, id: number): ScheduledTask | null { + const row = db + .prepare(` + SELECT st.*, + ${SCHEDULED_TASK_DISPLAY_SELECT} + FROM scheduled_tasks st + ${SCHEDULED_TASK_DISPLAY_JOIN} + WHERE st.id = ? + `) + .get(id) as any; + return row ? mapScheduledTask(row) : null; + } + + + export async function getScheduledTask(db: Database.Database, id: number, opts?: { viewer?: Express.User }): Promise { + const viewerClause = opts?.viewer + ? buildVisibilityWhere(opts.viewer, 'st', { spaceColumn: 'space_id' }) + : { clause: '1=1', params: [] as unknown[] }; + const row = db + .prepare(` + SELECT st.*, + ${SCHEDULED_TASK_DISPLAY_SELECT} + FROM scheduled_tasks st + ${SCHEDULED_TASK_DISPLAY_JOIN} + WHERE st.id = ? AND ${viewerClause.clause} + `) + .get(id, ...viewerClause.params) as any; + return row ? mapScheduledTask(row) : null; + } + + + export async function listScheduledTasks(db: Database.Database, filter?: { viewer?: Express.User; spaceId?: string | null }): Promise { + const conditions: string[] = []; + const queryParams: unknown[] = []; + if (filter?.viewer) { + const w = buildVisibilityWhere(filter.viewer, 'st', { spaceColumn: 'space_id' }); + conditions.push(w.clause); + queryParams.push(...w.params); + } + // Optional per-space scoping (the space's "Schedules" tab). null = only the + // space-less (personal) schedules; a string = exactly that space. + if (filter?.spaceId !== undefined) { + if (filter.spaceId === null) { + conditions.push('st.space_id IS NULL'); + } else { + conditions.push('st.space_id = ?'); + queryParams.push(filter.spaceId); + } + } + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + const rows = db + .prepare(` + SELECT st.*, + ${SCHEDULED_TASK_DISPLAY_SELECT} + FROM scheduled_tasks st + ${SCHEDULED_TASK_DISPLAY_JOIN} + ${whereClause} + ORDER BY st.created_at DESC + `) + .all(...queryParams) as any[]; + return rows.map(r => mapScheduledTask(r)); + } + + + /** + * due なスケジュールタスクをアトミックに claim して返す。 + * BEGIN IMMEDIATE で書き込みロックを即座に取得し、 + * 他のスケジューラーインスタンスとの重複実行を防止する。 + * claim されたタスクは next_run_at が遠い未来に設定されるため、 + * 他インスタンスに再取得されない。 + * 呼び出し側が実行後に正しい next_run_at を再設定する。 + */ + export async function getScheduledTasksDue(db: Database.Database): Promise { + // 十分遠い未来(claim マーカー) + const claimMarker = '9999-12-31 23:59:59'; + + // BEGIN IMMEDIATE: トランザクション開始時に RESERVED ロックを取得し、 + // 他の書き込みトランザクションとの競合を防ぐ + const txn = db.transaction(() => { + const rows = db + .prepare( + `SELECT * FROM scheduled_tasks + WHERE is_active = 1 AND next_run_at <= datetime('now') + ORDER BY next_run_at ASC` + ) + .all() as any[]; + + if (rows.length === 0) return []; + + // claim: next_run_at を遠い未来に設定して他インスタンスからの重複取得を防止 + const ids = rows.map((r: any) => r.id); + db + .prepare( + `UPDATE scheduled_tasks + SET next_run_at = ?, updated_at = datetime('now') + WHERE id IN (${ids.map(() => '?').join(',')})` + ) + .run(claimMarker, ...ids); + + return rows; + }); + + const rows = txn.immediate(); + return rows.map((r: any) => mapScheduledTask(r)); + } + + + export async function updateScheduledTask(db: Database.Database, id: number, params: UpdateScheduledTaskParams): Promise { + const sets: string[] = []; + const values: Record = { id }; + + if (params.title !== undefined) { sets.push('title = @title'); values.title = params.title; } + if (params.body !== undefined) { sets.push('body = @body'); values.body = params.body; } + if (params.pieceName !== undefined) { sets.push('piece_name = @pieceName'); values.pieceName = params.pieceName; } + if (params.profile !== undefined) { sets.push('profile = @profile'); values.profile = params.profile; } + if (params.outputFormat !== undefined) { sets.push('output_format = @outputFormat'); values.outputFormat = params.outputFormat; } + if (params.cronExpression !== undefined) { sets.push('cron_expression = @cronExpression'); values.cronExpression = params.cronExpression; } + if (params.nextRunAt !== undefined) { sets.push('next_run_at = @nextRunAt'); values.nextRunAt = params.nextRunAt; } + if (params.lastRunAt !== undefined) { sets.push('last_run_at = @lastRunAt'); values.lastRunAt = params.lastRunAt; } + if (params.lastJobId !== undefined) { sets.push('last_job_id = @lastJobId'); values.lastJobId = params.lastJobId; } + if (params.isActive !== undefined) { sets.push('is_active = @isActive'); values.isActive = params.isActive ? 1 : 0; } + if (params.visibility !== undefined) { sets.push('visibility = @visibility'); values.visibility = params.visibility; } + if (params.visibilityScopeOrgId !== undefined) { + sets.push('visibility_scope_org_id = @visibilityScopeOrgId'); + values.visibilityScopeOrgId = params.visibilityScopeOrgId; + } + if (params.browserSessionProfileId !== undefined) { + sets.push('browser_session_profile_id = @browserSessionProfileId'); + values.browserSessionProfileId = params.browserSessionProfileId; + } + if (params.taskKind !== undefined) { + sets.push('task_kind = @taskKind'); + values.taskKind = params.taskKind; + } + if (params.scriptName !== undefined) { + sets.push('script_name = @scriptName'); + values.scriptName = params.scriptName; + } + if (params.scriptParams !== undefined) { + sets.push('script_params = @scriptParams'); + values.scriptParams = params.scriptParams; + } + + if (sets.length === 0) return getScheduledTaskSync(db, id); + + sets.push("updated_at = datetime('now')"); + db.prepare(`UPDATE scheduled_tasks SET ${sets.join(', ')} WHERE id = @id`).run(values); + return getScheduledTaskSync(db, id); + } + + + export async function deleteScheduledTask(db: Database.Database, id: number): Promise { + const result = db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); + return result.changes > 0; + } diff --git a/src/db/repositories/schema.ts b/src/db/repositories/schema.ts new file mode 100644 index 0000000..bdeb491 --- /dev/null +++ b/src/db/repositories/schema.ts @@ -0,0 +1,538 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// schema repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { logger } from '../../logger.js'; +import { ensureTaskCommentFts } from '../migrate.js'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + + export function initSchema(db: Database.Database): void { + runPreSchemaCompatibilityMigrations(db); + const schemaPath = join(__dirname, '..', 'schema.sql'); + const schema = readFileSync(schemaPath, 'utf-8'); + db.exec(schema); + ensureColumn(db, 'jobs', 'required_profile', "TEXT NOT NULL DEFAULT 'auto'"); + ensureColumn(db, 'jobs', 'task_class', "TEXT NOT NULL DEFAULT 'auto'"); + ensureColumn(db, 'worker_nodes', 'profile_tags', "TEXT NOT NULL DEFAULT ',auto,'"); + ensureColumn(db, 'worker_nodes', 'task_class_tags', "TEXT NOT NULL DEFAULT ',auto,'"); + ensureColumn(db, 'worker_nodes', 'available_models', 'TEXT'); + ensureColumn(db, 'worker_nodes', 'max_concurrency', 'INTEGER NOT NULL DEFAULT 1'); + ensureColumn(db, 'worker_nodes', 'last_error', 'TEXT'); + db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_profile_task_class ON jobs (status, required_profile, task_class)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_worker_nodes_health ON worker_nodes (enabled, healthy, last_seen_at)"); + ensureColumn(db, 'jobs', 'parent_job_id', 'TEXT'); + ensureColumn(db, 'jobs', 'subtask_depth', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'jobs', 'wait_reason', 'TEXT'); + ensureColumn(db, 'jobs', 'continued_from_job_id', 'TEXT'); + // Phase A (multi-team GPU pool): physical backend id assigned when + // the worker is a proxy. Sticky once set; never overwritten mid-job. + ensureColumn(db, 'jobs', 'last_backend_id', 'TEXT'); + db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_parent_job_id ON jobs (parent_job_id)"); + ensureColumn(db, 'local_tasks', 'feedback_rating', 'TEXT'); + ensureColumn(db, 'local_tasks', 'feedback_comment', 'TEXT'); + ensureColumn(db, 'local_tasks', 'feedback_tags', 'TEXT'); + ensureColumn(db, 'local_tasks', 'feedback_at', 'TEXT'); + migrateWaitingSubtasksStatus(db); + // Auth migrations: owner_id columns + ensureColumn(db, 'jobs', 'owner_id', 'TEXT'); + ensureColumn(db, 'local_tasks', 'owner_id', 'TEXT'); + // #142: 実行中アクティビティ表示 + ensureColumn(db, 'jobs', 'current_activity', 'TEXT'); + // abortReason 細分化: agent-loop / piece-runner が出す構造化 abort code を保持 + ensureColumn(db, 'jobs', 'abort_reason', 'TEXT'); + // #134: 共有機能 + ensureColumn(db, 'local_tasks', 'share_token', 'TEXT'); + ensureColumn(db, 'local_tasks', 'shared_at', 'TEXT'); + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_local_tasks_share_token ON local_tasks (share_token)"); + + // Ownership and visibility columns (3 tables) + for (const table of ['local_tasks', 'scheduled_tasks', 'jobs']) { + ensureColumn(db, table, 'owner_id', 'TEXT'); + ensureColumn(db, table, 'visibility', "TEXT NOT NULL DEFAULT 'private'"); + ensureColumn(db, table, 'visibility_scope_org_id', 'TEXT'); + } + + // User preferences + ensureColumn(db, 'users', 'default_visibility', "TEXT NOT NULL DEFAULT 'private'"); + ensureColumn(db, 'users', 'default_visibility_org_id', 'TEXT'); + + // Indexes and user_gitea_orgs table + db.exec(` + CREATE INDEX IF NOT EXISTS idx_local_tasks_owner_id ON local_tasks(owner_id); + CREATE INDEX IF NOT EXISTS idx_local_tasks_visibility ON local_tasks(visibility, visibility_scope_org_id); + CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_owner_id ON scheduled_tasks(owner_id); + CREATE INDEX IF NOT EXISTS idx_jobs_owner_id ON jobs(owner_id); + CREATE INDEX IF NOT EXISTS idx_jobs_visibility ON jobs(visibility, visibility_scope_org_id); + + CREATE TABLE IF NOT EXISTS user_gitea_orgs ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id TEXT NOT NULL, + org_name TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, org_id) + ); + CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); + `); + + // スペース基盤(計画1)。schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'case', + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + owner_id TEXT, + visibility TEXT NOT NULL DEFAULT 'private', + visibility_scope_org_id TEXT, + status TEXT NOT NULL DEFAULT 'open', + brand_color TEXT, + workspace_dir TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id); + CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal'; + `); + ensureColumn(db, 'local_tasks', 'space_id', 'TEXT'); + ensureColumn(db, 'local_tasks', 'workspace_mode', "TEXT NOT NULL DEFAULT 'persistent'"); + ensureColumn(db, 'jobs', 'space_id', 'TEXT'); + ensureColumn(db, 'scheduled_tasks', 'space_id', 'TEXT'); + + // タスク横断検索用の会話コメント索引(通常テーブルのみ)。schema.sql / migrate.ts と三重ミラー。 + // FTS5 仮想テーブル+同期トリガは src/db/migrate.ts の ensureTaskCommentFts が単一定義箇所として作成する + // (runMigrations() を経由しない bare `new Repository()` 構築でも FTS が確実に存在するように、ここからも呼ぶ)。 + db.exec(` + CREATE TABLE IF NOT EXISTS task_comment_index ( + comment_id INTEGER PRIMARY KEY, + task_id INTEGER NOT NULL, + author TEXT, + kind TEXT, + created_at TEXT, + text TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_tci_task ON task_comment_index(task_id); + `); + ensureTaskCommentFts(db); + + // スペース・メンバー(協働者)。schema.sql / migrate.ts と三重ミラー。 + // owner は members 行を持たず owner_id で判定する。 + db.exec(` + CREATE TABLE IF NOT EXISTS space_members ( + space_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('owner','editor','viewer')), + invited_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (space_id, user_id), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members (user_id); + `); + + // スペース・カレンダー(予定)。schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id TEXT NOT NULL, + owner_id TEXT, + date TEXT NOT NULL, + end_date TEXT, + time TEXT, + end_time TEXT, + title TEXT NOT NULL, + description TEXT, + created_by TEXT NOT NULL DEFAULT 'user', + source_task_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date); + `); + + // スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS space_invites ( + token TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('editor','viewer')), + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, + revoked_at TEXT, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites (space_id); + `); + // 公開アプリ共有リンク(read-only)。schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS app_share_links ( + token TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + app_name TEXT NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + revoked_at TEXT, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_app_share_links_space_app + ON app_share_links(space_id, app_name); + `); + // ツール要求の記録(RequestTool 能動申告 / 未許可ツール呼び出しの受動捕捉)。 + // schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS tool_requests ( + id TEXT PRIMARY KEY, + task_id TEXT, + job_id TEXT, + space_id TEXT, + piece_name TEXT NOT NULL, + movement_name TEXT NOT NULL, + tool_name TEXT NOT NULL, + reason TEXT, + category TEXT NOT NULL DEFAULT 'requested', + status TEXT NOT NULL DEFAULT 'pending', + grant_scope TEXT, + decided_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_tool_requests_task ON tool_requests (task_id); + CREATE INDEX IF NOT EXISTS idx_tool_requests_piece ON tool_requests (piece_name); + CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status); + `); + // エージェント発 Python パッケージ要求(RequestPackage)。承認で PR2 の + // installSpacePackages を再利用しスペース overlay へ install する。 + // schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS package_requests ( + id TEXT PRIMARY KEY, + task_id TEXT, + job_id TEXT, + space_id TEXT, + piece_name TEXT, + movement_name TEXT, + spec TEXT NOT NULL, + normalized_name TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL DEFAULT 'pending', + decided_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_package_requests_task ON package_requests (task_id); + CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status); + `); + // ワークスペースファイル来歴台帳。schema.sql / migrate.ts と三重ミラー。 + db.exec(` + CREATE TABLE IF NOT EXISTS workspace_file_provenance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id TEXT, + workspace_path TEXT NOT NULL, + rel_path TEXT NOT NULL, + created_by_task_id INTEGER, + created_by_job_id TEXT, + created_by_piece TEXT, + created_by_movement TEXT, + source_kind TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_modified_by_task_id INTEGER, + last_modified_by_job_id TEXT, + last_modified_at TEXT, + checksum TEXT, + note TEXT, + UNIQUE(workspace_path, rel_path) + ); + CREATE INDEX IF NOT EXISTS idx_wsfile_prov_ws ON workspace_file_provenance (workspace_path); + CREATE INDEX IF NOT EXISTS idx_wsfile_prov_created ON workspace_file_provenance (workspace_path, created_by_task_id); + `); + // Per-space isolation for MCP servers / SSH connections (spaces foundation). + // NULL = global/legacy (resolved globally; Phase 1 is a no-op, no consumer + // reads this yet). Triple-path mirror: schema.sql + migrate.ts. + ensureColumn(db, 'mcp_servers', 'space_id', 'TEXT'); + ensureColumn(db, 'ssh_connections', 'space_id', 'TEXT'); + // workstream 2: ブラウザセッションプロファイルの per-space 化。owner_id は復号 + // DEK のため保持。Triple-path mirror: schema.sql + migrate.ts。 + ensureColumn(db, 'browser_session_profiles', 'space_id', 'TEXT'); + // 計画5: 実行ログ root。NULL = 後方互換で workspace_path/logs に解決。 + ensureColumn(db, 'local_tasks', 'runtime_dir', 'TEXT'); + ensureColumn(db, 'jobs', 'runtime_dir', 'TEXT'); + // Tool-request mechanism: per-task grant overlay (JSON string[]). + ensureColumn(db, 'local_tasks', 'granted_tools', 'TEXT'); + + // Browser session persistence (2026-05) — keep in sync with schema.sql + db.exec(` + CREATE TABLE IF NOT EXISTS user_deks ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + encrypted_dek BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS browser_session_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + space_id TEXT, + label TEXT NOT NULL, + start_url TEXT NOT NULL, + match_patterns TEXT NOT NULL DEFAULT '[]', + storage_origins TEXT NOT NULL DEFAULT '[]', + logged_in_selector TEXT, + login_url_patterns TEXT NOT NULL DEFAULT '[]', + encrypted_state_blob BLOB, + state_version INTEGER NOT NULL DEFAULT 0, + playwright_version TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked','error')), + last_saved_at TEXT, + last_used_at TEXT, + last_validated_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_bsp_owner ON browser_session_profiles(owner_id); + CREATE INDEX IF NOT EXISTS idx_bsp_space ON browser_session_profiles(space_id); + -- audit log: intentionally no FK — must survive deletion of referenced rows + CREATE TABLE IF NOT EXISTS browser_session_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + actor_user_id TEXT, + profile_id INTEGER, + owner_id TEXT, + action TEXT NOT NULL CHECK (action IN ('create','save','decrypt','use','delete','expire','revoke','test','login_start','login_cancel')), + task_id INTEGER, + job_id TEXT, + result TEXT NOT NULL CHECK (result IN ('success','error')), + reason TEXT + ); + CREATE INDEX IF NOT EXISTS idx_bsa_profile ON browser_session_audit(profile_id); + CREATE INDEX IF NOT EXISTS idx_bsa_actor ON browser_session_audit(actor_user_id); + `); + + ensureColumn(db, 'local_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); + ensureColumn(db, 'scheduled_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); + ensureColumn(db, 'jobs', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); + + // E: scheduled_tasks can now run a user script directly (without going + // through the agent / LLM loop). task_kind='agent' (default) keeps the + // pre-existing behavior; task_kind='script' uses script_name + script_params. + ensureColumn(db, 'scheduled_tasks', 'task_kind', "TEXT NOT NULL DEFAULT 'agent' CHECK (task_kind IN ('agent','script'))"); + ensureColumn(db, 'scheduled_tasks', 'script_name', 'TEXT'); + ensureColumn(db, 'scheduled_tasks', 'script_params', 'TEXT'); // JSON-encoded object or NULL + + // F: reflection jobs — task_kind distinguishes agent jobs from reflection + // jobs that run the self-improving-memory pipeline (no LLM task loop). + // payload carries JSON inputs (scope, trigger metadata, etc.). + ensureColumn(db, 'jobs', 'task_kind', "TEXT NOT NULL DEFAULT 'agent'"); + ensureColumn(db, 'jobs', 'payload', 'TEXT'); + + // G: reflection piece-edit cooldown tracking. + // reflection_piece_edits records each time the reflection pipeline writes + // a user's custom piece. The cooldown gate in piece-writer.ts queries + // countRecentPieceEdits to rate-limit piece rewrites. + db.exec(` + CREATE TABLE IF NOT EXISTS reflection_piece_edits ( + user_id TEXT NOT NULL, + piece_name TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, piece_name, created_at) + ); + CREATE INDEX IF NOT EXISTS idx_rpe_user_piece_time + ON reflection_piece_edits (user_id, piece_name, created_at DESC); + `); + + // The Side Info Panel dashboard widget feature was removed (2026-06); + // drop the legacy per-user widget table if an older DB still carries it. + db.exec(`DROP TABLE IF EXISTS user_dashboard_widgets;`); + + // H: reflection_metrics — one row per reflection job, records outcome, + // token usage, memory changes, and whether a piece edit was applied. + // Used by the /api/reflection/metrics endpoint (Phase 7.2) and future + // per-user budget enforcement (Phase 8.2). + db.exec(` + CREATE TABLE IF NOT EXISTS reflection_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reflection_job_id TEXT NOT NULL, + original_job_id TEXT, + user_id TEXT NOT NULL, + piece_name TEXT, + outcome TEXT NOT NULL, + memory_changes INTEGER NOT NULL DEFAULT 0, + piece_edited INTEGER NOT NULL DEFAULT 0, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_rm_user_time + ON reflection_metrics (user_id, created_at DESC); + `); + + // I: AAO Gateway Phase 2a — DB-backed virtual keys. + // Mirrors src/db/schema.sql and migrateGatewayVirtualKeys in migrate.ts; + // all three paths must stay in sync (project_db_migration_dual_path). + db.exec(` + CREATE TABLE IF NOT EXISTS gateway_virtual_keys ( + id TEXT PRIMARY KEY, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + team TEXT NOT NULL, + allowed_models TEXT, + source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')), + created_at TEXT NOT NULL, + created_by TEXT, + revoked_at TEXT, + revoked_by TEXT, + last_used_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active + ON gateway_virtual_keys (key_hash) + WHERE revoked_at IS NULL; + CREATE INDEX IF NOT EXISTS idx_gateway_keys_team + ON gateway_virtual_keys (team); + `); + + // I.b: AAO Gateway Phase 2b — budget / rate limit columns + + // gateway_key_usage table. Mirrors schema.sql + migrate.ts and uses + // the same PRAGMA-based idempotency pattern. + ensureColumn(db, 'gateway_virtual_keys', 'tokens_budget', 'INTEGER'); + ensureColumn(db, 'gateway_virtual_keys', 'rate_limit_rpm', 'INTEGER'); + db.exec(` + CREATE TABLE IF NOT EXISTS gateway_key_usage ( + key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE, + period_start TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (key_id, period_start) + ); + CREATE INDEX IF NOT EXISTS idx_gateway_usage_key + ON gateway_key_usage (key_id); + `); + + // Per-user daily LLM usage (gateway + direct). Mirrors schema.sql + + // migrate.ts (dual-path rule). Separate lens from gateway_key_usage. + // Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md + db.exec(` + CREATE TABLE IF NOT EXISTS llm_usage_daily ( + day TEXT NOT NULL, + user_id TEXT NOT NULL, + source TEXT NOT NULL, + model TEXT NOT NULL, + route TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (day, user_id, source, model, route) + ); + CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day + ON llm_usage_daily (user_id, day); + `); + + // Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the + // write target; daily kept as frozen archive). Mirrors schema.sql + + // migrate.ts (dual-path). Backfill of the daily archive lives in migrate.ts + // so it runs once on upgrade, not on every fresh initSchema. + // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md + db.exec(` + CREATE TABLE IF NOT EXISTS llm_usage_hourly ( + hour TEXT NOT NULL, + user_id TEXT NOT NULL, + source TEXT NOT NULL, + model TEXT NOT NULL, + route TEXT NOT NULL, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0, + last_updated_at TEXT NOT NULL, + PRIMARY KEY (hour, user_id, source, model, route) + ); + CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour + ON llm_usage_hourly (user_id, hour); + `); + } + + + export function runPreSchemaCompatibilityMigrations(db: Database.Database): void { + const hasBrowserSessionProfiles = db + .prepare("SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?") + .get("table", "browser_session_profiles"); + if (!hasBrowserSessionProfiles) { + return; + } + + const columns = db + .prepare("PRAGMA table_info(browser_session_profiles)") + .all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "space_id")) { + return; + } + + db.exec("ALTER TABLE browser_session_profiles ADD COLUMN space_id TEXT"); + } + + + export function ensureColumn(db: Database.Database, tableName: string, columnName: string, definition: string): void { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>; + if (columns.some((column) => column.name === columnName)) { + return; + } + db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`).run(); + } + + + export function migrateWaitingSubtasksStatus(db: Database.Database): void { + // Check if jobs table already has waiting_subtasks in its CHECK constraint + const tableInfo = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='jobs'" + ).get() as { sql: string } | undefined; + if (!tableInfo || tableInfo.sql.includes('waiting_subtasks')) return; + + // Recreate jobs table with updated CHECK constraint + logger.info('Repository: migrating jobs table to support waiting_subtasks status...'); + db.transaction(() => { + db.exec(` + CREATE TABLE IF NOT EXISTS jobs_v2 ( + id TEXT PRIMARY KEY, + repo TEXT NOT NULL, + issue_number INTEGER NOT NULL, + pr_number INTEGER, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')), + piece_name TEXT NOT NULL DEFAULT 'general', + required_profile TEXT NOT NULL DEFAULT 'auto', + task_class TEXT NOT NULL DEFAULT 'auto', + current_movement TEXT, + instruction TEXT NOT NULL DEFAULT '', + branch_name TEXT, + worktree_path TEXT, + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 3, + next_retry_at TEXT, + error_summary TEXT, + resume_movement TEXT, + ask_count INTEGER NOT NULL DEFAULT 0, + worker_id TEXT, + parent_job_id TEXT, + subtask_depth INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO jobs_v2 + SELECT id, repo, issue_number, pr_number, status, piece_name, required_profile, task_class, + current_movement, instruction, branch_name, worktree_path, attempt, max_attempts, + next_retry_at, error_summary, resume_movement, ask_count, worker_id, + NULL AS parent_job_id, 0 AS subtask_depth, created_at, updated_at + FROM jobs; + DROP TABLE jobs; + ALTER TABLE jobs_v2 RENAME TO jobs; + `); + })(); + logger.info('Repository: jobs table migration complete'); + } diff --git a/src/db/repositories/shared.ts b/src/db/repositories/shared.ts new file mode 100644 index 0000000..cf5559d --- /dev/null +++ b/src/db/repositories/shared.ts @@ -0,0 +1,44 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// shared repository domain. Facade: src/db/repository.ts (Repository class). + +/** Escape LIKE wildcards so a caller-supplied prefix matches literally. */ +export function escapeLike(s: string): string { + return s.replace(/[\\%_]/g, (c) => '\\' + c); +} + + +export function encodeTags(values: string[]): string { + const unique = Array.from(new Set(values.filter(Boolean))); + return `,${unique.join(',')},`; +} + + +export function decodeTags(raw: string | null): string[] { + if (!raw) return []; + return raw.split(',').map((value) => value.trim()).filter(Boolean); +} + + +export function decodeAvailableModels(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []; + } catch { + return []; + } +} + + +/** SQLite datetime('now') は UTC だがタイムゾーン情報なし。'Z' を付加して ISO 8601 UTC として明示する */ +export function utc(dt: string | null): string { + if (!dt) return ''; + // 既に Z や +/- オフセットが付いていれば何もしない + if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt)) return dt; + return dt.replace(' ', 'T') + 'Z'; +} + + +export function localTaskRepoName(taskId: number): string { + return `local/task-${taskId}`; +} diff --git a/src/db/repositories/spaces.ts b/src/db/repositories/spaces.ts new file mode 100644 index 0000000..cd5a4b2 --- /dev/null +++ b/src/db/repositories/spaces.ts @@ -0,0 +1,495 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// spaces repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; +import { buildVisibilityWhere, buildSpaceVisibilityWhere } from '../../bridge/visibility.js'; +import { utc } from './shared.js'; + +export interface SpaceRow { + id: string; + kind: string; + title: string; + description: string; + owner_id: string | null; + visibility: string; + visibility_scope_org_id: string | null; + status: string; + brand_color: string | null; + workspace_dir: string | null; + created_at: string; + updated_at: string; +} + + +export interface Space { + id: string; + kind: 'personal' | 'case'; + title: string; + description: string; + ownerId: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + status: 'open' | 'archived'; + brandColor: string | null; + workspaceDir: string | null; + createdAt: string; + updatedAt: string; +} + + +export type SpaceMemberRoleValue = 'owner' | 'editor' | 'viewer'; + + +/** space_members の 1 行 + users から合成した表示情報。owner_id は別途 owner として扱う。 */ +export interface SpaceMember { + spaceId: string; + userId: string; + role: SpaceMemberRoleValue; + invitedBy: string | null; + createdAt: string; + /** users JOIN 由来の表示用フィールド(行が無い場合 null)。 */ + name: string | null; + email: string | null; + avatarUrl: string | null; +} + + +/** 招待リンクで付与できるロール。owner は付与不可(権限昇格防止)。 */ +export type SpaceInviteRole = 'editor' | 'viewer'; + + +/** space_invites の 1 行。 */ +export interface SpaceInvite { + token: string; + spaceId: string; + role: SpaceInviteRole; + createdBy: string | null; + createdAt: string; + /** ISO 文字列。null = 無期限。 */ + expiresAt: string | null; + /** ISO 文字列。null = 有効。 */ + revokedAt: string | null; +} + + +export interface CreateSpaceParams { + id?: string; + kind: 'personal' | 'case'; + title: string; + description?: string; + ownerId: string | null; + visibility?: 'private' | 'org' | 'public'; + visibilityScopeOrgId?: string | null; + brandColor?: string | null; + workspaceDir?: string | null; +} + + +export function rowToSpace(row: SpaceRow): Space { + return { + id: row.id, + kind: row.kind as Space['kind'], + title: row.title, + description: row.description, + ownerId: row.owner_id, + visibility: row.visibility as Space['visibility'], + visibilityScopeOrgId: row.visibility_scope_org_id, + status: row.status as Space['status'], + brandColor: row.brand_color, + workspaceDir: row.workspace_dir, + createdAt: utc(row.created_at), + updatedAt: utc(row.updated_at), + }; +} + + + export async function createSpace(db: Database.Database, params: CreateSpaceParams): Promise { + const id = params.id ?? randomUUID(); + db + .prepare( + `INSERT INTO spaces (id, kind, title, description, owner_id, visibility, visibility_scope_org_id, status, brand_color, workspace_dir) + VALUES (@id, @kind, @title, @description, @ownerId, @visibility, @visibilityScopeOrgId, 'open', @brandColor, @workspaceDir)` + ) + .run({ + id, + kind: params.kind, + title: params.title, + description: params.description ?? '', + ownerId: params.ownerId, + visibility: params.visibility ?? 'private', + visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, + brandColor: params.brandColor ?? null, + workspaceDir: params.workspaceDir ?? null, + }); + const space = await getSpace(db, id); + if (!space) throw new Error('createSpace: failed to load inserted space'); + return space; + } + + + export async function getSpace(db: Database.Database, id: string, opts?: { viewer?: Express.User }): Promise { + // 個人スペースは所有者のみ可視(admin 含む)。buildSpaceVisibilityWhere が + // kind='personal' を owner_id 一致に限定し、kind='case' は従来通り扱う。 + const viewerClause = opts?.viewer + ? buildSpaceVisibilityWhere(opts.viewer, 's') + : { clause: '1=1', params: [] as unknown[] }; + const row = db + .prepare(`SELECT s.* FROM spaces s WHERE s.id = ? AND ${viewerClause.clause}`) + .get(id, ...viewerClause.params) as SpaceRow | undefined; + return row ? rowToSpace(row) : null; + } + + + export async function listSpaces(db: Database.Database, opts?: { viewer?: Express.User; includeArchived?: boolean }): Promise { + // 個人スペースは所有者のみ可視(admin 含む)。これにより admin の一覧/レールに + // 他ユーザーの個人スペースが混ざらず、UI の誤ワークスペース解決を防ぐ。 + const viewerClause = opts?.viewer + ? buildSpaceVisibilityWhere(opts.viewer, 's') + : { clause: '1=1', params: [] as unknown[] }; + const statusClause = opts?.includeArchived ? '1=1' : "s.status = 'open'"; + const rows = db + .prepare(` + SELECT s.* FROM spaces s + WHERE ${statusClause} AND ${viewerClause.clause} + ORDER BY s.kind = 'personal' DESC, s.updated_at DESC + `) + .all(...viewerClause.params) as SpaceRow[]; + return rows.map(rowToSpace); + } + + + export async function updateSpace(db: Database.Database, id: string, patch: { title?: string; description?: string; brandColor?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null }): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + if (patch.title !== undefined) { sets.push('title = ?'); vals.push(patch.title); } + if (patch.description !== undefined) { sets.push('description = ?'); vals.push(patch.description); } + if (patch.brandColor !== undefined) { sets.push('brand_color = ?'); vals.push(patch.brandColor); } + if (patch.visibility !== undefined) { sets.push('visibility = ?'); vals.push(patch.visibility); } + if (patch.visibilityScopeOrgId !== undefined) { sets.push('visibility_scope_org_id = ?'); vals.push(patch.visibilityScopeOrgId); } + if (sets.length === 0) return getSpace(db, id); + sets.push("updated_at = datetime('now')"); + db.prepare(`UPDATE spaces SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); + return getSpace(db, id); + } + + + export async function archiveSpace(db: Database.Database, id: string): Promise { + db + .prepare(`UPDATE spaces SET status = 'archived', updated_at = datetime('now') WHERE id = ? AND kind = 'case'`) + .run(id); + } + + + export async function setSpaceWorkspaceDir(db: Database.Database, id: string, workspaceDir: string): Promise { + db + .prepare(`UPDATE spaces SET workspace_dir = ?, updated_at = datetime('now') WHERE id = ?`) + .run(workspaceDir, id); + } + + + /** + * ロールバック専用のハード削除。通常は archiveSpace を使う。 + * 個人スペースは「削除不可」(spec §5.7) なので kind='case' のみ消す。 + * この backstop により、誤って personal id を渡しても個人スペースは保護される。 + */ + export async function hardDeleteSpace(db: Database.Database, id: string): Promise { + const tx = db.transaction((sid: string) => { + // Crypto-shred space-owned credential DEKs EXPLICITLY (Space-as-Principal P2). + // We don't rely on FK ON DELETE CASCADE because migrate.ts intentionally + // omits FK clauses, so a migrated DB's space_ssh_deks would otherwise + // outlive the space. Gate on kind='case' (subquery) so a mistaken personal + // id never shreds a personal space's DEK. Runs before the spaces delete so + // the guard subquery still sees the row. + const isCase = `AND space_id IN (SELECT id FROM spaces WHERE kind = 'case')`; + db.prepare(`DELETE FROM space_ssh_deks WHERE space_id = ? ${isCase}`).run(sid); + db.prepare(`DELETE FROM space_browser_deks WHERE space_id = ? ${isCase}`).run(sid); + db.prepare(`DELETE FROM spaces WHERE id = ? AND kind = 'case'`).run(sid); + }); + tx(id); + } + + + /** + * ユーザーの個人スペースを返す。無ければ生成する(冪等)。 + * UNIQUE INDEX idx_spaces_personal_owner により1ユーザー1個に保たれる。 + * 既存ユーザーの初回アクセス移行を兼ねる(spec §7)。 + * 個人スペースの workspace_dir はこの計画では NULL(フォルダ物理ワイヤリングは計画2)。 + */ + export async function ensurePersonalSpace(db: Database.Database, ownerId: string, displayName?: string): Promise { + const existing = db + .prepare(`SELECT s.* FROM spaces s WHERE s.owner_id = ? AND s.kind = 'personal'`) + .get(ownerId) as SpaceRow | undefined; + if (existing) return rowToSpace(existing); + return createSpace(db, { + kind: 'personal', + title: displayName ? `${displayName} の個人ワークスペース` : '個人ワークスペース', + ownerId, + visibility: 'private', + }); + } + + + // ─── スペース・メンバー(協働者)CRUD ───────────────────────────── + // owner_id は members 行を持たない根オーナー。これらは追加協働者だけを扱う。 + + /** + * メンバーを追加または上書きする(冪等)。既存行があれば role/invited_by を更新。 + * owner_id 本人を渡してはならない(既に owner)。呼び出し側でガードする。 + */ + export async function addSpaceMember(db: Database.Database, params: { + spaceId: string; + userId: string; + role: SpaceMemberRoleValue; + invitedBy?: string | null; + }): Promise { + db + .prepare( + `INSERT INTO space_members (space_id, user_id, role, invited_by) + VALUES (@spaceId, @userId, @role, @invitedBy) + ON CONFLICT(space_id, user_id) DO UPDATE SET + role = excluded.role, + invited_by = excluded.invited_by` + ) + .run({ + spaceId: params.spaceId, + userId: params.userId, + role: params.role, + invitedBy: params.invitedBy ?? null, + }); + } + + + /** スペースのメンバー一覧(users JOIN で表示情報付き)。owner_id は含まない。 */ + export async function listSpaceMembers(db: Database.Database, spaceId: string): Promise { + const rows = db + .prepare(` + SELECT sm.space_id, sm.user_id, sm.role, sm.invited_by, sm.created_at, + u.name AS name, u.email AS email, u.avatar_url AS avatar_url + FROM space_members sm + LEFT JOIN users u ON u.id = sm.user_id + WHERE sm.space_id = ? + ORDER BY sm.created_at ASC, sm.user_id ASC + `) + .all(spaceId) as Array<{ + space_id: string; + user_id: string; + role: string; + invited_by: string | null; + created_at: string; + name: string | null; + email: string | null; + avatar_url: string | null; + }>; + return rows.map((r) => ({ + spaceId: r.space_id, + userId: r.user_id, + role: r.role as SpaceMemberRoleValue, + invitedBy: r.invited_by, + createdAt: r.created_at, + name: r.name, + email: r.email, + avatarUrl: r.avatar_url, + })); + } + + + /** + * 指定ユーザーのメンバーロールを返す。メンバー行が無ければ null。 + * 根オーナー(owner_id)は members 行を持たないため null を返す。 + * 呼び出し側は owner_id を別途 'owner' として扱うこと(canManageSpace 参照)。 + */ + export function getSpaceMemberRole(db: Database.Database, spaceId: string, userId: string): SpaceMemberRoleValue | null { + const row = db + .prepare(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`) + .get(spaceId, userId) as { role: string } | undefined; + return row ? (row.role as SpaceMemberRoleValue) : null; + } + + + /** スペースのツール制限ポリシー(JSON 文字列)を返す。未設定なら null。 */ + export function getSpaceToolPolicy(db: Database.Database, spaceId: string): string | null { + const row = db + .prepare(`SELECT tool_policy FROM spaces WHERE id = ?`) + .get(spaceId) as { tool_policy: string | null } | undefined; + return row?.tool_policy ?? null; + } + + + /** スペースのツール制限ポリシーを更新する。null を渡すとクリア。 */ + export function setSpaceToolPolicy(db: Database.Database, spaceId: string, policyJson: string | null): void { + db + .prepare(`UPDATE spaces SET tool_policy = ?, updated_at = datetime('now') WHERE id = ?`) + .run(policyJson, spaceId); + } + + + /** スペースの Python パッケージ desired-state(JSON 文字列)を返す。未設定なら null。 */ + export function getSpacePythonPackages(db: Database.Database, spaceId: string): string | null { + const row = db + .prepare(`SELECT python_packages FROM spaces WHERE id = ?`) + .get(spaceId) as { python_packages: string | null } | undefined; + return row?.python_packages ?? null; + } + + + /** スペースの Python パッケージ desired-state を更新する。null を渡すとクリア。 */ + export function setSpacePythonPackages(db: Database.Database, spaceId: string, packagesJson: string | null): void { + db + .prepare(`UPDATE spaces SET python_packages = ?, updated_at = datetime('now') WHERE id = ?`) + .run(packagesJson, spaceId); + } + + + /** + * 指定 user がスペースの行(タスク/ファイル/ログ等)を VIEW できるか。 + * buildVisibilityWhere の membership OR ブランチと同じルールをコード上で再現する: + * admin → true / space.owner_id === user.id(根オーナー)→ true / + * space_members に (spaceId, user.id) 行あり → true / それ以外 → false。 + * 二次ゲート(canViewTask / canUserSeeTask)が呼び出す。書き込み判定ではない。 + */ + export function userCanViewSpace(db: Database.Database, spaceId: string, user: { id: string; role: string }): boolean { + if (user.role === 'admin') return true; + const owns = db + .prepare(`SELECT 1 FROM spaces WHERE id = ? AND owner_id = ?`) + .get(spaceId, user.id); + if (owns) return true; + const member = db + .prepare(`SELECT 1 FROM space_members WHERE space_id = ? AND user_id = ?`) + .get(spaceId, user.id); + return !!member; + } + + + /** 既存メンバーのロールを変更する。行が無ければ no-op。 */ + export async function updateSpaceMemberRole(db: Database.Database, spaceId: string, userId: string, role: SpaceMemberRoleValue): Promise { + db + .prepare(`UPDATE space_members SET role = ? WHERE space_id = ? AND user_id = ?`) + .run(role, spaceId, userId); + } + + + /** メンバーを除去する。行が無ければ no-op。 */ + export async function removeSpaceMember(db: Database.Database, spaceId: string, userId: string): Promise { + db + .prepare(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`) + .run(spaceId, userId); + } + + + // ─── スペース招待リンク(再利用トークン)───────────────────────────── + + export function rowToSpaceInvite(row: { + token: string; + space_id: string; + role: string; + created_by: string | null; + created_at: string; + expires_at: string | null; + revoked_at: string | null; + }): SpaceInvite { + return { + token: row.token, + spaceId: row.space_id, + role: row.role as SpaceInviteRole, + createdBy: row.created_by, + createdAt: row.created_at, + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }; + } + + + /** + * 招待が今この瞬間に有効か(無効化されておらず、期限切れでない)。 + * 期限比較は SQLite と同じ datetime('now')(UTC)基準の ISO 文字列辞書順。 + */ + export function isSpaceInviteValid(db: Database.Database, invite: SpaceInvite | null | undefined): invite is SpaceInvite { + if (!invite) return false; + if (invite.revokedAt) return false; + if (invite.expiresAt) { + const now = db.prepare(`SELECT datetime('now') AS n`).get() as { n: string }; + if (invite.expiresAt <= now.n) return false; + } + return true; + } + + + /** + * スペースの招待リンクを (再)生成する。再利用方針=スペースごとに有効リンク1本。 + * 既存 invite 行は削除してから新トークンを 1 行 insert する。 + * @param expiresInDays 有効日数。未指定/null は無期限。期限は SQLite の + * datetime('now','+N days') で保存し、isSpaceInviteValid と同じ形式に揃える。 + */ + export function createSpaceInvite(db: Database.Database, params: { + spaceId: string; + role: SpaceInviteRole; + createdBy?: string | null; + expiresInDays?: number | null; + }): SpaceInvite { + const token = randomUUID(); + // 整数日数のみ受理(負/0/非整数は無期限扱い)。文字列補間するため厳格に検証する。 + const days = + typeof params.expiresInDays === 'number' && + Number.isInteger(params.expiresInDays) && + params.expiresInDays > 0 + ? params.expiresInDays + : null; + const tx = db.transaction(() => { + db.prepare(`DELETE FROM space_invites WHERE space_id = ?`).run(params.spaceId); + db + .prepare( + `INSERT INTO space_invites (token, space_id, role, created_by, expires_at) + VALUES (@token, @spaceId, @role, @createdBy, + ${days === null ? 'NULL' : `datetime('now', '+${days} days')`})`, + ) + .run({ + token, + spaceId: params.spaceId, + role: params.role, + createdBy: params.createdBy ?? null, + }); + }); + tx(); + return getSpaceInviteByToken(db, token)!; + } + + + /** スペースの現行 invite(有効・無効問わず最新の1本)。無ければ null。 */ + export function getSpaceInvite(db: Database.Database, spaceId: string): SpaceInvite | null { + const row = db + .prepare(`SELECT * FROM space_invites WHERE space_id = ? ORDER BY created_at DESC LIMIT 1`) + .get(spaceId) as Parameters[0] | undefined; + return row ? rowToSpaceInvite(row) : null; + } + + + /** トークンで invite を引く(有効性は呼び出し側で判定)。無ければ null。 */ + export function getSpaceInviteByToken(db: Database.Database, token: string): SpaceInvite | null { + const row = db + .prepare(`SELECT * FROM space_invites WHERE token = ?`) + .get(token) as Parameters[0] | undefined; + return row ? rowToSpaceInvite(row) : null; + } + + + /** スペースの現行 invite を無効化する。行が無ければ no-op。 */ + export function revokeSpaceInvite(db: Database.Database, spaceId: string): void { + db + .prepare( + `UPDATE space_invites SET revoked_at = datetime('now') + WHERE space_id = ? AND revoked_at IS NULL`, + ) + .run(spaceId); + } + + + export function getSpaceA2aSkills(db: Database.Database, spaceId: string): string[] { + const r = db.prepare('SELECT a2a_skills FROM spaces WHERE id = ?').get(spaceId) as { a2a_skills: string | null } | undefined; + if (!r || !r.a2a_skills) return []; + try { const v = JSON.parse(r.a2a_skills); return Array.isArray(v) ? v : []; } catch { return []; } + } + + + export function setSpaceA2aSkills(db: Database.Database, spaceId: string, skills: string[]): void { + db.prepare('UPDATE spaces SET a2a_skills = ? WHERE id = ?').run(JSON.stringify(skills), spaceId); + } diff --git a/src/db/repositories/task-search.ts b/src/db/repositories/task-search.ts new file mode 100644 index 0000000..e2872ae --- /dev/null +++ b/src/db/repositories/task-search.ts @@ -0,0 +1,144 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// task-search repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { logger } from '../../logger.js'; +import { normalizeCommentToIndexText } from '../../engine/task-index/normalize.js'; +import * as localTasksRepo from './local-tasks.js'; + + /** + * Construct a WorkspaceTaskSearchIO scoped to the current task + owner. + * Used by SearchWorkspaceTasks / ReadWorkspaceTaskAround; searchWorkspaceTasks / + * readWorkspaceTaskAround themselves enforce the space/owner scoping, this is + * just the binding closure. fts5Available lets the tool degrade gracefully + * when the FTS5 index table is missing (e.g. pre-migration DB). + */ + export function makeWorkspaceTaskSearchIO(db: Database.Database, currentTaskId: number, ownerId: string): import('../../engine/tools/core.js').WorkspaceTaskSearchIO { + const fts5Available = !!db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_comment_fts'") + .get(); + return { + fts5Available, + search: (query, opts) => searchWorkspaceTasks(db, currentTaskId, ownerId, query, opts), + around: (commentId, before, after) => + readWorkspaceTaskAround(db, currentTaskId, ownerId, commentId, before, after), + }; + } + + + /** best-effort に1コメントを索引する。失敗してもコメント書き込みを壊さない。 */ + export function indexTaskComment(db: Database.Database, commentId: number, taskId: number, author: string, kind: string, createdAt: string, body: string, attachments: string | null): void { + try { + const text = normalizeCommentToIndexText(kind, body, attachments); + if (text === null) return; + db.prepare( + `INSERT OR IGNORE INTO task_comment_index (comment_id, task_id, author, kind, created_at, text) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(commentId, taskId, author, kind, createdAt, text); + } catch (err) { + logger.warn(`[task-index] indexTaskComment failed comment=${commentId}: ${(err as Error).message}`); + } + } + + + /** + * 正規化済みの各語を個別に引用して FTS5 MATCH 文字列を組む(Google 的な暗黙 AND)。 + * 各語をダブルクォートで囲むので、`AND`/`OR`/`NEAR`/`*`/`"` などが語に混じっても + * FTS5 演算子として解釈されず、インジェクションを防ぐ。語同士はこちらが制御する + * ` AND ` でつなぐため「全語を含む」検索になる。呼び出し側で引用符除去済みを前提とする。 + */ + export function buildFtsMatch(terms: string[]): string { + return terms.map((t) => `"${t}"`).join(' AND '); + } + + + export async function searchWorkspaceTasks(db: Database.Database, currentTaskId: number, ownerId: string, query: string, opts: { limit?: number; kind?: string; author?: string; taskId?: number } = {}): Promise> { + const q = (query ?? '').trim(); + if (!q) return []; + const cur = db.prepare('SELECT space_id, owner_id FROM local_tasks WHERE id = ?') + .get(currentTaskId) as { space_id: string | null; owner_id: string | null } | undefined; + if (!cur) return []; + + // スペース帰属確認(owner または space_members メンバー)。非帰属なら空。 + if (cur.space_id) { + const member = db.prepare( + `SELECT 1 FROM spaces WHERE id = @s AND owner_id = @u + UNION SELECT 1 FROM space_members WHERE space_id = @s AND user_id = @u`, + ).get({ s: cur.space_id, u: ownerId }); + if (!member) return []; + } + + const limit = Math.min(Math.max(opts.limit ?? 10, 1), 30); + // Google 的な暗黙 AND: 空白区切りの各語を「すべて含む」ものにヒットさせる。 + // 各語から埋め込み二重引用符を除去(空白化)してトリムし、空になった語は捨てる。 + // これで純粋な記号だけの「語」が AND を汚染して常に0件になるのを防ぐ。 + const terms = q.split(/\s+/).map((t) => t.replace(/"/g, ' ').trim()).filter(Boolean); + if (terms.length === 0) return []; // 引用符・記号のみで実質的な検索語が無い + // trigram は3文字未満の語を MATCH できないため、短い語が1つでもあれば LIKE 経路 + // (語ごとの AND-of-LIKE。LIKE は語長に依存せず動く)にフォールバックする。 + const useLike = terms.some((t) => t.length < 3); + const scope = cur.space_id + ? 'lt.space_id = @spaceId' + : 'lt.space_id IS NULL AND lt.owner_id = @ownerId'; + const filters = [ + opts.kind ? 'i.kind = @kind' : '', + opts.author ? 'i.author = @author' : '', + opts.taskId ? 'i.task_id = @taskFilter' : '', + ].filter(Boolean).join(' AND '); + const filterSql = filters ? ` AND ${filters}` : ''; + + const params: Record = { + cur: currentTaskId, spaceId: cur.space_id, ownerId, + kind: opts.kind, author: opts.author, taskFilter: opts.taskId, limit, + }; + + let sql: string; + if (useLike) { + // 各語を LIKE で AND 結合(位置・順序を問わず全語を含む)。値はパラメータ束縛、 + // 列名・プレースホルダ名は固定文字列なのでインジェクションしない。 + const likeConds = terms.map((_, i) => `i.text LIKE @like${i}`).join(' AND '); + terms.forEach((t, i) => { params[`like${i}`] = `%${t}%`; }); + sql = ` + SELECT i.task_id AS taskId, lt.title AS taskTitle, i.comment_id AS commentId, + i.author, i.kind, i.created_at AS createdAt, i.text + FROM task_comment_index i + JOIN local_tasks lt ON lt.id = i.task_id + WHERE (${likeConds}) AND i.task_id != @cur AND (${scope})${filterSql} + ORDER BY i.created_at DESC LIMIT @limit`; + } else { + params.match = buildFtsMatch(terms); + sql = ` + SELECT i.task_id AS taskId, lt.title AS taskTitle, i.comment_id AS commentId, + i.author, i.kind, i.created_at AS createdAt, i.text + FROM task_comment_fts f + JOIN task_comment_index i ON i.comment_id = f.rowid + JOIN local_tasks lt ON lt.id = i.task_id + WHERE f.text MATCH @match AND i.task_id != @cur AND (${scope})${filterSql} + ORDER BY bm25(task_comment_fts) LIMIT @limit`; + } + return db.prepare(sql).all(params) as any; + } + + + export async function readWorkspaceTaskAround(db: Database.Database, currentTaskId: number, ownerId: string, commentId: number, before: number, after: number) { + const cur = db.prepare('SELECT space_id, owner_id FROM local_tasks WHERE id = ?') + .get(currentTaskId) as { space_id: string | null; owner_id: string | null } | undefined; + if (!cur) return null; + const target = db.prepare('SELECT task_id FROM local_task_comments WHERE id = ?') + .get(commentId) as { task_id: number } | undefined; + if (!target) return null; + const t = db.prepare('SELECT id, title, space_id, owner_id FROM local_tasks WHERE id = ?') + .get(target.task_id) as { id: number; title: string; space_id: string | null; owner_id: string | null } | undefined; + if (!t) return null; + // スコープ確認: 同一スペース(or 同 owner の個人スペース) + const inScope = cur.space_id ? t.space_id === cur.space_id : (t.space_id === null && t.owner_id === ownerId); + if (!inScope) return null; + + const all = await localTasksRepo.listLocalTaskComments(db, target.task_id); + const pos = all.findIndex((x) => x.id === commentId); + if (pos < 0) return null; + const slice = all.slice(Math.max(0, pos - before), pos + after + 1); + return { + taskId: t.id, taskTitle: t.title, + comments: slice.map((x) => ({ commentId: x.id, author: x.author, kind: x.kind, createdAt: x.createdAt, body: x.body, isCenter: x.id === commentId })), + }; + } diff --git a/src/db/repositories/tool-requests.ts b/src/db/repositories/tool-requests.ts new file mode 100644 index 0000000..5f2cf8c --- /dev/null +++ b/src/db/repositories/tool-requests.ts @@ -0,0 +1,395 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// tool-requests repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + +export type ToolRequestCategory = 'requested' | 'blocked' | 'unknown'; + +export type ToolRequestStatus = 'pending' | 'approved' | 'denied' | 'auto_denied'; + + +/** 現在の movement で使えないツールの要求記録(能動 RequestTool / 受動 block 捕捉)。 */ +export interface ToolRequest { + id: string; + taskId: string | null; + jobId: string | null; + spaceId: string | null; + pieceName: string; + movementName: string; + toolName: string; + reason: string | null; + category: ToolRequestCategory; + status: ToolRequestStatus; + grantScope: 'task' | 'piece' | null; + decidedBy: string | null; + createdAt: string; + decidedAt: string | null; +} + + +export interface ToolRequestRow { + id: string; + task_id: string | null; + job_id: string | null; + space_id: string | null; + piece_name: string; + movement_name: string; + tool_name: string; + reason: string | null; + category: string; + status: string; + grant_scope: string | null; + decided_by: string | null; + created_at: string; + decided_at: string | null; +} + + +export function rowToToolRequest(row: ToolRequestRow): ToolRequest { + return { + id: row.id, + taskId: row.task_id ?? null, + jobId: row.job_id ?? null, + spaceId: row.space_id ?? null, + pieceName: row.piece_name, + movementName: row.movement_name, + toolName: row.tool_name, + reason: row.reason ?? null, + category: (row.category as ToolRequestCategory) ?? 'requested', + status: (row.status as ToolRequestStatus) ?? 'pending', + grantScope: (row.grant_scope as 'task' | 'piece' | null) ?? null, + decidedBy: row.decided_by ?? null, + createdAt: row.created_at, + decidedAt: row.decided_at ?? null, + }; +} + + +/** ピース集計の1行(tool_name×category ごとの件数)。 */ +export interface ToolRequestAggregate { + toolName: string; + category: ToolRequestCategory; + count: number; + lastSeen: string; + sampleReason: string | null; +} + + + /** + * Record a tool request — either an agent's RequestTool declaration or a + * passively-captured blocked tool call. De-dups within the same job: an + * identical still-pending (job_id, movement_name, tool_name) row is reused + * so a retry loop does not spam the table. Returns the row id. + */ + export function recordToolRequest(db: Database.Database, params: { + id?: string; + taskId?: string | null; + jobId?: string | null; + spaceId?: string | null; + pieceName: string; + movementName: string; + toolName: string; + reason?: string | null; + category?: ToolRequestCategory; + status?: ToolRequestStatus; + }): string { + const category: ToolRequestCategory = params.category ?? 'requested'; + const status: ToolRequestStatus = params.status ?? 'pending'; + // De-dup pending rows so a retry loop never grows the table without bound + // (the table has no GC). Scope by the most specific identity available — + // job → task → piece — so de-dup still works for runs without a job + // binding. scopeCol is a fixed identifier (not user input) → no injection. + const scopeCol: 'job_id' | 'task_id' | 'piece_name' = params.jobId + ? 'job_id' + : params.taskId + ? 'task_id' + : 'piece_name'; + const scopeVal = params.jobId ?? params.taskId ?? params.pieceName; + const existing = db + .prepare( + `SELECT id FROM tool_requests + WHERE ${scopeCol} = ? AND movement_name = ? AND tool_name = ? AND status = 'pending' + LIMIT 1`, + ) + .get(scopeVal, params.movementName, params.toolName) as { id: string } | undefined; + if (existing) return existing.id; + const id = params.id ?? randomUUID(); + db + .prepare( + `INSERT INTO tool_requests + (id, task_id, job_id, space_id, piece_name, movement_name, tool_name, reason, category, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + params.taskId ?? null, + params.jobId ?? null, + params.spaceId ?? null, + params.pieceName, + params.movementName, + params.toolName, + params.reason ?? null, + category, + status, + ); + return id; + } + + + /** All tool requests for a task, newest first. */ + export function listToolRequestsByTask(db: Database.Database, taskId: string): ToolRequest[] { + const rows = db + .prepare(`SELECT * FROM tool_requests WHERE task_id = ? ORDER BY created_at DESC, id DESC`) + .all(taskId) as ToolRequestRow[]; + return rows.map(rowToToolRequest); + } + + + /** Per-piece aggregation: count + last-seen + a sample reason per + * (tool_name, category). `sampleReason` is one representative reason (not + * necessarily the most recent — it is a MAX over the group). */ + export function aggregateToolRequestsByPiece(db: Database.Database, pieceName: string): ToolRequestAggregate[] { + const rows = db + .prepare( + `SELECT tool_name, category, COUNT(*) AS count, + MAX(created_at) AS last_seen, MAX(reason) AS sample_reason + FROM tool_requests + WHERE piece_name = ? + GROUP BY tool_name, category + ORDER BY count DESC, tool_name ASC`, + ) + .all(pieceName) as Array<{ + tool_name: string; category: string; count: number; last_seen: string; sample_reason: string | null; + }>; + return rows.map((r) => ({ + toolName: r.tool_name, + category: (r.category as ToolRequestCategory) ?? 'requested', + count: r.count, + lastSeen: r.last_seen, + sampleReason: r.sample_reason ?? null, + })); + } + + + /** Fetch a single tool request by id. */ + export function getToolRequest(db: Database.Database, id: string): ToolRequest | null { + const row = db.prepare(`SELECT * FROM tool_requests WHERE id = ?`).get(id) as ToolRequestRow | undefined; + return row ? rowToToolRequest(row) : null; + } + + + /** + * Record an approve/deny/auto-deny decision on a still-pending tool request. + * Returns false if the row is missing or already decided (idempotent / race + * safe — a concurrent decide won't double-apply). + */ + export function decideToolRequest(db: Database.Database, id: string, decision: { status: Exclude; grantScope?: 'task' | 'piece' | null; decidedBy?: string | null }): boolean { + const res = db + .prepare( + `UPDATE tool_requests + SET status = ?, grant_scope = ?, decided_by = ?, decided_at = datetime('now') + WHERE id = ? AND status = 'pending'`, + ) + .run(decision.status, decision.grantScope ?? null, decision.decidedBy ?? null, id); + return res.changes > 0; + } + + + /** Per-task grant overlay (tool names approved inline for this task). */ + export function getGrantedTools(db: Database.Database, taskId: string): string[] { + const row = db.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as + | { granted_tools: string | null } + | undefined; + if (!row?.granted_tools) return []; + try { + const parsed = JSON.parse(row.granted_tools); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; + } catch { + return []; + } + } + + + /** Add a tool to a task's grant overlay (idempotent — de-duped). Returns the new set. */ + export function addGrantedTool(db: Database.Database, taskId: string, toolName: string): string[] { + const current = getGrantedTools(db, taskId); + if (current.includes(toolName)) return current; + const next = [...current, toolName]; + db.prepare(`UPDATE local_tasks SET granted_tools = ? WHERE id = ?`).run(JSON.stringify(next), taskId); + return next; + } + +// ─── Package requests (RequestPackage → 承認 → install) ────────────────────── + +export type PackageRequestStatus = 'pending' | 'approved' | 'denied' | 'auto_denied'; + +/** エージェント発 Python パッケージ要求(RequestPackage)の記録。 */ +export interface PackageRequest { + id: string; + taskId: string | null; + jobId: string | null; + spaceId: string | null; + pieceName: string | null; + movementName: string | null; + spec: string; + normalizedName: string; + reason: string | null; + status: PackageRequestStatus; + decidedBy: string | null; + createdAt: string; + decidedAt: string | null; +} + +export interface PackageRequestRow { + id: string; + task_id: string | null; + job_id: string | null; + space_id: string | null; + piece_name: string | null; + movement_name: string | null; + spec: string; + normalized_name: string; + reason: string | null; + status: string; + decided_by: string | null; + created_at: string; + decided_at: string | null; +} + +export function rowToPackageRequest(row: PackageRequestRow): PackageRequest { + return { + id: row.id, + taskId: row.task_id ?? null, + jobId: row.job_id ?? null, + spaceId: row.space_id ?? null, + pieceName: row.piece_name ?? null, + movementName: row.movement_name ?? null, + spec: row.spec, + normalizedName: row.normalized_name, + reason: row.reason ?? null, + status: (row.status as PackageRequestStatus) ?? 'pending', + decidedBy: row.decided_by ?? null, + createdAt: row.created_at, + decidedAt: row.decided_at ?? null, + }; +} + +/** + * Record an agent-declared Python package request (RequestPackage). De-dups + * within the same job: an identical still-pending (job_id, normalized_name) + * row is reused so a retry loop does not spam the table. Returns the row id. + */ +export function recordPackageRequest(db: Database.Database, params: { + id?: string; + taskId?: string | null; + jobId?: string | null; + spaceId?: string | null; + pieceName?: string | null; + movementName?: string | null; + spec: string; + normalizedName: string; + reason?: string | null; + status?: PackageRequestStatus; +}): string { + const status: PackageRequestStatus = params.status ?? 'pending'; + const scopeCol: 'job_id' | 'task_id' = params.jobId ? 'job_id' : 'task_id'; + const scopeVal = params.jobId ?? params.taskId ?? null; + if (scopeVal !== null) { + const existing = db + .prepare( + `SELECT id FROM package_requests + WHERE ${scopeCol} = ? AND normalized_name = ? AND status = 'pending' + LIMIT 1`, + ) + .get(scopeVal, params.normalizedName) as { id: string } | undefined; + if (existing) return existing.id; + } + const id = params.id ?? randomUUID(); + db + .prepare( + `INSERT INTO package_requests + (id, task_id, job_id, space_id, piece_name, movement_name, spec, normalized_name, reason, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + id, + params.taskId ?? null, + params.jobId ?? null, + params.spaceId ?? null, + params.pieceName ?? null, + params.movementName ?? null, + params.spec, + params.normalizedName, + params.reason ?? null, + status, + ); + return id; +} + +/** All package requests for a task, newest first. */ +export function listPackageRequestsByTask(db: Database.Database, taskId: string): PackageRequest[] { + const rows = db + .prepare(`SELECT * FROM package_requests WHERE task_id = ? ORDER BY created_at DESC, id DESC`) + .all(taskId) as PackageRequestRow[]; + return rows.map(rowToPackageRequest); +} + +/** + * Normalized names declined (denied OR auto_denied) for a job. RequestPackage + * uses this on resume to short-circuit a re-request instead of re-parking — + * otherwise a deny would loop (deny → movement re-runs → re-request → park). + */ +export function listDeclinedPackageNamesByJob(db: Database.Database, jobId: string): string[] { + const rows = db + .prepare( + `SELECT DISTINCT normalized_name FROM package_requests + WHERE job_id = ? AND status IN ('denied', 'auto_denied')`, + ) + .all(jobId) as Array<{ normalized_name: string }>; + return rows.map((r) => r.normalized_name); +} + +/** Fetch a single package request by id. */ +export function getPackageRequest(db: Database.Database, id: string): PackageRequest | null { + const row = db.prepare(`SELECT * FROM package_requests WHERE id = ?`).get(id) as PackageRequestRow | undefined; + return row ? rowToPackageRequest(row) : null; +} + +/** + * Record an approve/deny/auto-deny decision on a still-pending package request. + * Returns false if the row is missing or already decided (idempotent / race + * safe — a concurrent decide won't double-apply, so the install path can gate + * on this to avoid a double install). + */ +export function decidePackageRequest( + db: Database.Database, + id: string, + decision: { status: Exclude; decidedBy?: string | null }, +): boolean { + const res = db + .prepare( + `UPDATE package_requests + SET status = ?, decided_by = ?, decided_at = datetime('now') + WHERE id = ? AND status = 'pending'`, + ) + .run(decision.status, decision.decidedBy ?? null, id); + return res.changes > 0; +} + +/** + * Revert an approved request whose install then FAILED back to pending, so it + * can be retried. CAS on the current 'approved' state (no-op otherwise). The + * approve path claims (pending→approved) BEFORE installing precisely so a + * concurrent deny can never leave a denied request with an installed package; + * on install failure it reverts here. + */ +export function revertPackageRequestToPending(db: Database.Database, id: string): boolean { + const res = db + .prepare( + `UPDATE package_requests + SET status = 'pending', decided_by = NULL, decided_at = NULL + WHERE id = ? AND status = 'approved'`, + ) + .run(id); + return res.changes > 0; +} diff --git a/src/db/repositories/users.ts b/src/db/repositories/users.ts new file mode 100644 index 0000000..ed33f94 --- /dev/null +++ b/src/db/repositories/users.ts @@ -0,0 +1,563 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// users repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; +import { buildVisibilityWhere } from '../../bridge/visibility.js'; +import { utc } from './shared.js'; + +export interface User { + id: string; + email: string; + name: string | null; + avatarUrl: string | null; + role: 'admin' | 'user'; + status: 'active' | 'pending' | 'disabled'; + defaultVisibility: 'private' | 'org' | 'public'; + defaultVisibilityOrgId: string | null; + createdAt: string; + updatedAt: string; +} + + +export interface CreateUserParams { + email: string; + name: string; + role: 'admin' | 'user'; + status: 'active' | 'pending' | 'disabled'; + avatarUrl?: string; +} + + +export interface FindOrCreateByOAuthParams { + provider: string; + providerId: string; + email: string; + name: string; + avatarUrl?: string; +} + + +export interface CreateLocalUserParams { + email: string; + password: string; + role: 'admin' | 'user'; + status: 'active' | 'pending' | 'disabled'; + name?: string; +} + + +export interface LocalOrg { + id: string; + name: string; + createdBy: string | null; + createdAt: string; +} + + +export interface LocalOrgMember { + userId: string; + role: string; +} + + +export interface GiteaOrgInput { + orgId: string; + orgName: string; +} + + +export interface GiteaOrg extends GiteaOrgInput { + fetchedAt: string; +} + + +export interface UserRow { + id: string; + email: string; + name: string | null; + avatar_url: string | null; + role: string; + status: string; + default_visibility: string | null; + default_visibility_org_id: string | null; + created_at: string; + updated_at: string; +} + + +export function rowToUser(row: UserRow): User { + return { + id: row.id, + email: row.email, + name: row.name, + avatarUrl: row.avatar_url, + role: row.role as 'admin' | 'user', + status: row.status as 'active' | 'pending' | 'disabled', + defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'], + defaultVisibilityOrgId: row.default_visibility_org_id, + createdAt: utc(row.created_at), + updatedAt: utc(row.updated_at), + }; +} + + + export function createUser(db: Database.Database, params: CreateUserParams): User { + const id = uuidv4(); + const now = new Date().toISOString(); + db + .prepare( + `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) + VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)` + ) + .run({ + id, + email: params.email, + name: params.name, + avatarUrl: params.avatarUrl ?? null, + role: params.role, + status: params.status, + now, + }); + const user = getUserById(db, id); + if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`); + return user; + } + + + /** + * Ensure the synthetic 'local' user row exists. No-auth single-user + * deployments own per-user rows under the id 'local' (tasks, jobs, SSH + * connections, DEKs, …). Many of those tables FK to users(id) with + * foreign_keys ON, so the row must exist or the inserts fail — e.g. + * ssh_user_deks → SSH connection creation returned create_failed. + * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup. + * role='admin' mirrors the synthetic 'local' user the HTTP layer injects + * for task-visibility routes in no-auth mode. + */ + export function ensureLocalUser(db: Database.Database): void { + const now = new Date().toISOString(); + db + .prepare( + `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) + VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)` + ) + .run({ now }); + } + + + // ── Local auth (email + password) ───────────────────────────────────── + + /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */ + export function setLocalPassword(db: Database.Database, userId: string, plainPassword: string): void { + const salt = randomBytes(16).toString('hex'); + const hash = scryptSync(plainPassword, salt, 64).toString('hex'); + const now = new Date().toISOString(); + db + .prepare( + `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at) + VALUES (@userId, @hash, @salt, @now) + ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`, + ) + .run({ userId, hash, salt, now }); + } + + + /** Constant-time verify. False when the user has no local credential. */ + export function verifyLocalPassword(db: Database.Database, userId: string, plainPassword: string): boolean { + const row = db + .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?') + .get(userId) as { password_hash: string; salt: string } | undefined; + if (!row) return false; + const expected = Buffer.from(row.password_hash, 'hex'); + const actual = scryptSync(plainPassword, row.salt, expected.length); + return expected.length === actual.length && timingSafeEqual(expected, actual); + } + + + export function hasLocalCredential(db: Database.Database, userId: string): boolean { + return !!db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId); + } + + + /** + * Create a brand-new local account (self-signup or admin-created). The email + * MUST be unused: attaching a password to an existing account would be an + * account-takeover vector, so we reject instead of linking. Linking a local + * credential to an existing OAuth account is a separate, authenticated action + * (not v1 signup). + */ + export function createLocalUser(db: Database.Database, params: CreateLocalUserParams): User { + if (getUserByEmail(db, params.email)) { + throw new Error(`createLocalUser: a user with email ${params.email} already exists`); + } + const user = createUser(db, { + email: params.email, + name: params.name ?? params.email, + role: params.role, + status: params.status, + }); + db + .prepare( + `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) + VALUES (@id, @userId, 'local', @providerId, @now)`, + ) + .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() }); + setLocalPassword(db, user.id, params.password); + return user; + } + + + /** + * Idempotently seed the shared system admin under the fixed id `local` — the + * same owner the no-auth path synthesizes. This makes all pre-existing + * `local`-owned data belong to the logged-in admin once local auth is turned + * on, and lets an existing no-auth deployment gain a login mid-stream. + * Re-running updates the password and keeps role=admin/status=active. + */ + export function upsertLocalSystemAdmin(db: Database.Database, params: { email: string; password: string; name?: string }): User { + const LOCAL_ID = 'local'; + const now = new Date().toISOString(); + const existing = getUserById(db, LOCAL_ID); + if (!existing) { + db + .prepare( + `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) + VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`, + ) + .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now }); + } else { + db + .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`) + .run({ id: LOCAL_ID, email: params.email, now }); + } + db + .prepare( + `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) + VALUES (@id, @userId, 'local', @providerId, @now)`, + ) + .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now }); + setLocalPassword(db, LOCAL_ID, params.password); + const user = getUserById(db, LOCAL_ID); + if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin'); + return user; + } + + + // ── Local organizations ─────────────────────────────────────────────── + + export function rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg { + return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at }; + } + + + /** Create a local org. id is prefixed `lorg:` so it never collides with a + * Gitea numeric org id (both live in visibility_scope_org_id). */ + export function createLocalOrg(db: Database.Database, name: string, createdBy: string | null): LocalOrg { + const id = `lorg:${uuidv4()}`; + const now = new Date().toISOString(); + db + .prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`) + .run({ id, name, createdBy, now }); + return { id, name, createdBy, createdAt: now }; + } + + + export function getLocalOrg(db: Database.Database, id: string): LocalOrg | null { + const r = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as + | { id: string; name: string; created_by: string | null; created_at: string } + | undefined; + return r ? rowToLocalOrg(r) : null; + } + + + export function listLocalOrgs(db: Database.Database): LocalOrg[] { + const rows = db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>; + return rows.map(r => rowToLocalOrg(r)); + } + + + export function renameLocalOrg(db: Database.Database, id: string, name: string): void { + db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id); + } + + /** Tables carrying `visibility_scope_org_id` (org-scoped resources). */ + const ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs']; + + + /** + * Delete a local org. Members cascade via FK. Resources scoped to this org + * (visibility='org', visibility_scope_org_id=id) would become invisible to + * everyone once the org is gone — so first downgrade them to 'private' + * (owner + admin can still see them; no data loss). Atomic. + */ + export function deleteLocalOrg(db: Database.Database, id: string): void { + const tx = db.transaction((orgId: string) => { + for (const table of ORG_SCOPED_TABLES) { + db + .prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`) + .run(orgId); + } + db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId); + }); + tx(id); + } + + + /** Add or update a member (idempotent — re-add updates the role). */ + export function addOrgMember(db: Database.Database, orgId: string, userId: string, role: string = 'member'): void { + const now = new Date().toISOString(); + db + .prepare( + `INSERT INTO local_org_members (org_id, user_id, role, added_at) + VALUES (@orgId, @userId, @role, @now) + ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`, + ) + .run({ orgId, userId, role, now }); + } + + + export function removeOrgMember(db: Database.Database, orgId: string, userId: string): void { + db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId); + } + + + export function listOrgMembers(db: Database.Database, orgId: string): LocalOrgMember[] { + const rows = db + .prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at') + .all(orgId) as Array<{ user_id: string; role: string }>; + return rows.map(r => ({ userId: r.user_id, role: r.role })); + } + + + /** Orgs a user belongs to — merged into session.orgIds so the existing + * provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */ + export function listUserLocalOrgs(db: Database.Database, userId: string): Array<{ orgId: string; name: string }> { + const rows = db + .prepare( + `SELECT o.id AS org_id, o.name AS name + FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id + WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`, + ) + .all(userId) as Array<{ org_id: string; name: string }>; + return rows.map(r => ({ orgId: r.org_id, name: r.name })); + } + + + export function getUserById(db: Database.Database, id: string): User | null { + const row = db + .prepare('SELECT * FROM users WHERE id = ?') + .get(id) as UserRow | undefined; + return row ? rowToUser(row) : null; + } + + + export function getUserByEmail(db: Database.Database, email: string): User | null { + const row = db + .prepare('SELECT * FROM users WHERE email = ?') + .get(email) as UserRow | undefined; + return row ? rowToUser(row) : null; + } + + + export function findOrCreateUserByOAuth(db: Database.Database, params: FindOrCreateByOAuthParams): User { + // 1. Check if oauth_account already exists + const existing = db + .prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?') + .get(params.provider, params.providerId) as { user_id: string } | undefined; + + if (existing) { + const user = getUserById(db, existing.user_id); + if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`); + // Sync mutable profile fields from the provider on every re-login so + // existing users whose name was missing on first login pick it up once + // their Gitea profile is populated. Email upgrade only applies when the + // dummy @gitea.local placeholder is being replaced. + const patch: { email?: string; name?: string; avatarUrl?: string | null } = {}; + if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) { + patch.email = params.email; + } + if (params.name && params.name !== user.name) patch.name = params.name; + if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) { + patch.avatarUrl = params.avatarUrl; + } + if (Object.keys(patch).length > 0) { + updateUser(db, user.id, patch); + const refreshed = getUserById(db, user.id); + if (refreshed) return refreshed; + } + return user; + } + + // 2. Check if user exists by email + let user = getUserByEmail(db, params.email); + + if (!user) { + // 3. Create new user with status=pending + user = createUser(db, { + email: params.email, + name: params.name, + role: 'user', + status: 'pending', + avatarUrl: params.avatarUrl, + }); + } + + // 4. Link oauth_account to user + const oauthId = uuidv4(); + const now = new Date().toISOString(); + db + .prepare( + `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) + VALUES (@id, @userId, @provider, @providerId, @now)` + ) + .run({ + id: oauthId, + userId: user.id, + provider: params.provider, + providerId: params.providerId, + now, + }); + + return user; + } + + + export function listUsers(db: Database.Database): User[] { + const rows = db + .prepare('SELECT * FROM users ORDER BY created_at ASC') + .all() as UserRow[]; + return rows.map(row => rowToUser(row)); + } + + + /** + * Active users who share at least one organization with the given org id + * set (Gitea org cache `user_gitea_orgs` OR local org membership + * `local_org_members`). Used by the member-invite picker so a requester only + * sees collaborators inside their own org(s) — exposing the full user list is + * a privacy leak. Empty `orgIds` → empty result. Deduplicated by user id. + */ + export function listActiveUsersInOrgs(db: Database.Database, orgIds: string[]): User[] { + if (orgIds.length === 0) return []; + const placeholders = orgIds.map(() => '?').join(', '); + const rows = db + .prepare( + `SELECT u.* FROM users u + WHERE u.status = 'active' AND u.id IN ( + SELECT user_id FROM user_gitea_orgs WHERE org_id IN (${placeholders}) + UNION + SELECT user_id FROM local_org_members WHERE org_id IN (${placeholders}) + ) + ORDER BY u.created_at ASC`, + ) + .all(...orgIds, ...orgIds) as UserRow[]; + return rows.map(row => rowToUser(row)); + } + + + export function updateUser(db: Database.Database, id: string, updates: { + status?: 'active' | 'pending' | 'disabled'; + role?: 'admin' | 'user'; + email?: string; + name?: string; + avatarUrl?: string | null; + defaultVisibility?: 'private' | 'org' | 'public'; + defaultVisibilityOrgId?: string | null; + }): void { + const setClauses: string[] = ["updated_at = datetime('now')"]; + const params: Record = { id }; + + if (updates.status !== undefined) { + setClauses.push('status = @status'); + params['status'] = updates.status; + } + if (updates.role !== undefined) { + setClauses.push('role = @role'); + params['role'] = updates.role; + } + if (updates.email !== undefined) { + setClauses.push('email = @email'); + params['email'] = updates.email; + } + if (updates.name !== undefined) { + setClauses.push('name = @name'); + params['name'] = updates.name; + } + if (updates.avatarUrl !== undefined) { + setClauses.push('avatar_url = @avatar_url'); + params['avatar_url'] = updates.avatarUrl; + } + if (updates.defaultVisibility !== undefined) { + setClauses.push('default_visibility = @default_visibility'); + params['default_visibility'] = updates.defaultVisibility; + } + if (updates.defaultVisibilityOrgId !== undefined) { + setClauses.push('default_visibility_org_id = @default_visibility_org_id'); + params['default_visibility_org_id'] = updates.defaultVisibilityOrgId; + } + + if (setClauses.length === 1) return; + + db + .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`) + .run(params); + } + + + export function deleteUser(db: Database.Database, id: string): void { + // Never delete the shared `local` system/admin user: it is the no-auth + // fallback owner and owns all single-user-mode data. Deleting it would + // break no-auth mode and orphan every `local`-owned task/job/folder. + if (id === 'local') { + throw new Error('cannot delete the local/system user'); + } + db.prepare('DELETE FROM users WHERE id = ?').run(id); + } + + + export function deleteSessionsByUserId(db: Database.Database, userId: string): void { + // Sessions store passport user info as JSON in sess column + // Delete sessions where sess contains the user id + const rows = db + .prepare('SELECT sid, sess FROM sessions') + .all() as Array<{ sid: string; sess: string }>; + + const toDelete: string[] = []; + for (const row of rows) { + try { + const sess = JSON.parse(row.sess) as Record; + const passport = sess['passport'] as Record | undefined; + if (passport && passport['user'] === userId) { + toDelete.push(row.sid); + } + } catch { + // ignore parse errors + } + } + + if (toDelete.length > 0) { + const placeholders = toDelete.map(() => '?').join(', '); + db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete); + } + } + + + export function replaceUserGiteaOrgs(db: Database.Database, userId: string, orgs: GiteaOrgInput[]): void { + const tx = db.transaction((uid: string, items: GiteaOrgInput[]) => { + db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid); + const insert = db.prepare( + 'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)' + ); + for (const o of items) insert.run(uid, o.orgId, o.orgName); + }); + tx(userId, orgs); + } + + + export function listUserGiteaOrgs(db: Database.Database, userId: string): GiteaOrg[] { + const rows = db + .prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC') + .all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>; + return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at })); + } diff --git a/src/db/repositories/worker-nodes.ts b/src/db/repositories/worker-nodes.ts new file mode 100644 index 0000000..c1acac0 --- /dev/null +++ b/src/db/repositories/worker-nodes.ts @@ -0,0 +1,140 @@ +// Extracted from src/db/repository.ts (mechanical split — bodies unchanged). +// worker-nodes repository domain. Facade: src/db/repository.ts (Repository class). +import Database from 'better-sqlite3'; +import { encodeTags, decodeTags, decodeAvailableModels, utc } from './shared.js'; + +export interface WorkerNode { + workerId: string; + endpoint: string; + enabled: boolean; + healthy: boolean; + roles: string[]; + availableModels: string[]; + inflightJobs: number; + maxConcurrency: number; + lastError: string | null; + lastSeenAt: string; + updatedAt: string; +} + + +export interface UpsertWorkerNodeParams { + workerId: string; + endpoint: string; + enabled: boolean; + healthy: boolean; + roles: string[]; + availableModels?: string[]; + inflightJobs?: number; + maxConcurrency?: number; + lastError?: string | null; +} + + +export interface WorkerNodeRow { + worker_id: string; + endpoint: string; + enabled: number; + healthy: number; + profile_tags: string; + task_class_tags: string; + available_models: string | null; + inflight_jobs: number; + max_concurrency: number; + last_error: string | null; + last_seen_at: string; + updated_at: string; +} + + +export function rowToWorkerNode(row: WorkerNodeRow): WorkerNode { + return { + workerId: row.worker_id, + endpoint: row.endpoint, + enabled: row.enabled === 1, + healthy: row.healthy === 1, + roles: decodeTags(row.profile_tags), + availableModels: decodeAvailableModels(row.available_models), + inflightJobs: row.inflight_jobs, + maxConcurrency: row.max_concurrency, + lastError: row.last_error, + lastSeenAt: utc(row.last_seen_at), + updatedAt: utc(row.updated_at), + }; +} + + + export async function upsertWorkerNode(db: Database.Database, params: UpsertWorkerNodeParams): Promise { + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO worker_nodes ( + worker_id, endpoint, enabled, healthy, profile_tags, task_class_tags, available_models, + inflight_jobs, max_concurrency, last_error, last_seen_at, updated_at + ) VALUES ( + @workerId, @endpoint, @enabled, @healthy, @roleTags, @roleTags, @availableModels, + @inflightJobs, @maxConcurrency, @lastError, @now, @now + ) + ON CONFLICT(worker_id) DO UPDATE SET + endpoint = excluded.endpoint, + enabled = excluded.enabled, + healthy = excluded.healthy, + profile_tags = excluded.profile_tags, + task_class_tags = excluded.task_class_tags, + available_models = excluded.available_models, + inflight_jobs = excluded.inflight_jobs, + max_concurrency = excluded.max_concurrency, + last_error = excluded.last_error, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at + `).run({ + workerId: params.workerId, + endpoint: params.endpoint, + enabled: params.enabled ? 1 : 0, + healthy: params.healthy ? 1 : 0, + roleTags: encodeTags(params.roles), + availableModels: JSON.stringify(params.availableModels ?? []), + inflightJobs: params.inflightJobs ?? 0, + maxConcurrency: params.maxConcurrency ?? 1, + lastError: params.lastError ?? null, + now, + }); + } + + + export async function updateWorkerNodeHealth(db: Database.Database, workerId: string, updates: { healthy: boolean; lastError?: string | null; inflightJobs?: number; availableModels?: string[]; enabled?: boolean }): Promise { + const setClauses = [ + 'healthy = @healthy', + 'last_error = @lastError', + "last_seen_at = @now", + "updated_at = @now", + ]; + const params: Record = { + workerId, + healthy: updates.healthy ? 1 : 0, + lastError: updates.lastError ?? null, + now: new Date().toISOString(), + }; + + if (updates.inflightJobs !== undefined) { + setClauses.push('inflight_jobs = @inflightJobs'); + params['inflightJobs'] = updates.inflightJobs; + } + if (updates.availableModels !== undefined) { + setClauses.push('available_models = @availableModels'); + params['availableModels'] = JSON.stringify(updates.availableModels); + } + if (updates.enabled !== undefined) { + setClauses.push('enabled = @enabled'); + params['enabled'] = updates.enabled ? 1 : 0; + } + + db.prepare(`UPDATE worker_nodes SET ${setClauses.join(', ')} WHERE worker_id = @workerId`).run(params); + } + + + export async function getWorkerNode(db: Database.Database, workerId: string): Promise { + const row = db + .prepare('SELECT * FROM worker_nodes WHERE worker_id = ?') + .get(workerId) as WorkerNodeRow | undefined; + return row ? rowToWorkerNode(row) : null; + } diff --git a/src/db/repository.a2a-revocation.test.ts b/src/db/repository.a2a-revocation.test.ts index a3fbac5..e89dd4e 100644 --- a/src/db/repository.a2a-revocation.test.ts +++ b/src/db/repository.a2a-revocation.test.ts @@ -70,6 +70,55 @@ describe('A2A revocation repo primitives', () => { expect(repo.listLiveA2aDelegationsForClient('c1', now).map(d => d.id)).toEqual(['live']); }); + it('countNonTerminalA2aTasksByGrant counts only non-terminal tasks for the grant', async () => { + // Seed: working + completed + rejected + input-required for g1, plus working for g2 + repo.saveA2aTask({ id: 'cnt-working', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'working' } }, grantId: 'g1' }); + repo.saveA2aTask({ id: 'cnt-completed', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'completed' } }, grantId: 'g1' }); + repo.saveA2aTask({ id: 'cnt-rejected', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'rejected' } }, grantId: 'g1' }); + repo.saveA2aTask({ id: 'cnt-input-required', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'input-required' } }, grantId: 'g1' }); + repo.saveA2aTask({ id: 'cnt-g2-working', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'working' } }, grantId: 'g2' }); + // g1: only the 'working' task counts — completed, rejected, and input-required are excluded. + // input-required is parked (no live worker/LLM) so must not block new task creation. + expect(repo.countNonTerminalA2aTasksByGrant('g1')).toBe(1); + // g2: one non-terminal task + expect(repo.countNonTerminalA2aTasksByGrant('g2')).toBe(1); + // unknown grant: zero + expect(repo.countNonTerminalA2aTasksByGrant('nope')).toBe(0); + + // listNonTerminalA2aTasksByGrant must still include input-required for revocation coverage. + const listed = repo.listNonTerminalA2aTasksByGrant('g1').map(r => r.id); + expect(listed).toContain('cnt-working'); + expect(listed).toContain('cnt-input-required'); + expect(listed).not.toContain('cnt-completed'); + expect(listed).not.toContain('cnt-rejected'); + }); + + it('countNonTerminalA2aTasksByGrant counts an input-required task once its job resumes (no ASK-park bypass)', async () => { + // A parked input-required task (job still waiting_human) must NOT count — holds no capacity. + const parked = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'p' }); + (repo as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }) + .db.prepare("UPDATE jobs SET status = 'waiting_human' WHERE id = ?").run(parked.id); + repo.saveA2aTask({ id: 'ir-parked', contextId: null, jobId: parked.id, localTaskId: null, + payload: { status: { state: 'input-required' } }, grantId: 'gx' }); + expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(0); + + // The human answers → job resumes to 'running' while the a2a_tasks payload still reads + // 'input-required'. That resumed task consumes capacity again and MUST count. + (repo as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => void } } }) + .db.prepare("UPDATE jobs SET status = 'running' WHERE id = ?").run(parked.id); + expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(1); + + // An input-required task whose job is gone (jobId null) stays excluded — nothing running. + repo.saveA2aTask({ id: 'ir-nojob', contextId: null, jobId: null, localTaskId: null, + payload: { status: { state: 'input-required' } }, grantId: 'gx' }); + expect(repo.countNonTerminalA2aTasksByGrant('gx')).toBe(1); + }); + // sonnet review Finding 2: 二重失効で元の revoked_at を上書きしない it('revokeA2aDelegation does not overwrite an existing revoked_at', () => { const first = '2026-07-02T00:00:00.000Z'; diff --git a/src/db/repository.package-requests.test.ts b/src/db/repository.package-requests.test.ts new file mode 100644 index 0000000..e70b25e --- /dev/null +++ b/src/db/repository.package-requests.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from './repository.js'; + +let tempDir = ''; +let repo: Repository; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'maestro-pkgreq-')); + repo = new Repository(join(tempDir, 'db.sqlite')); +}); +afterEach(() => { + repo.close(); + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('package_requests repository', () => { + it('records, fetches and lists a request', () => { + const id = repo.recordPackageRequest({ + taskId: '100', jobId: 'job-1', spaceId: 'space-1', + pieceName: 'chat', movementName: 'work', + spec: 'requests==2.32.3', normalizedName: 'requests', reason: 'http', + }); + const got = repo.getPackageRequest(id); + expect(got?.spec).toBe('requests==2.32.3'); + expect(got?.normalizedName).toBe('requests'); + expect(got?.status).toBe('pending'); + expect(got?.spaceId).toBe('space-1'); + expect(got?.movementName).toBe('work'); + + const list = repo.listPackageRequestsByTask('100'); + expect(list).toHaveLength(1); + expect(list[0].id).toBe(id); + }); + + it('de-dups an identical still-pending request within the same job', () => { + const a = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'requests', normalizedName: 'requests' }); + const b = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'requests', normalizedName: 'requests' }); + expect(b).toBe(a); + expect(repo.listPackageRequestsByTask('1')).toHaveLength(1); + }); + + it('does NOT de-dup once the first is decided (a fresh request can be raised)', () => { + const a = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'numpy', normalizedName: 'numpy' }); + expect(repo.decidePackageRequest(a, { status: 'denied', decidedBy: 'u1' })).toBe(true); + const b = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'numpy', normalizedName: 'numpy' }); + expect(b).not.toBe(a); + expect(repo.listPackageRequestsByTask('1')).toHaveLength(2); + }); + + it('decide is a CAS: a second decide on the same row is a no-op', () => { + const id = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'pandas', normalizedName: 'pandas' }); + expect(repo.decidePackageRequest(id, { status: 'approved', decidedBy: 'u1' })).toBe(true); + expect(repo.decidePackageRequest(id, { status: 'denied', decidedBy: 'u2' })).toBe(false); + const got = repo.getPackageRequest(id); + expect(got?.status).toBe('approved'); + expect(got?.decidedBy).toBe('u1'); + expect(got?.decidedAt).not.toBeNull(); + }); + + it('revertPackageRequestToPending un-claims an approved request (for install-failure retry)', () => { + const id = repo.recordPackageRequest({ taskId: '1', jobId: 'j', spec: 'httpx', normalizedName: 'httpx' }); + // claim + expect(repo.decidePackageRequest(id, { status: 'approved', decidedBy: 'u1' })).toBe(true); + // revert (install failed) + expect(repo.revertPackageRequestToPending(id)).toBe(true); + const got = repo.getPackageRequest(id); + expect(got?.status).toBe('pending'); + expect(got?.decidedBy).toBeNull(); + expect(got?.decidedAt).toBeNull(); + // can be claimed again + expect(repo.decidePackageRequest(id, { status: 'approved', decidedBy: 'u2' })).toBe(true); + // revert only touches 'approved' rows: a denied row is untouched + const denied = repo.recordPackageRequest({ taskId: '1', jobId: 'k', spec: 'numpy', normalizedName: 'numpy' }); + repo.decidePackageRequest(denied, { status: 'denied', decidedBy: 'u1' }); + expect(repo.revertPackageRequestToPending(denied)).toBe(false); + expect(repo.getPackageRequest(denied)?.status).toBe('denied'); + }); + + it('listDeclinedPackageNamesByJob returns denied + auto_denied names for the job only', () => { + const denied = repo.recordPackageRequest({ taskId: '1', jobId: 'jA', spec: 'httpx', normalizedName: 'httpx' }); + repo.decidePackageRequest(denied, { status: 'denied', decidedBy: 'u1' }); + const auto = repo.recordPackageRequest({ taskId: '1', jobId: 'jA', spec: 'boto3', normalizedName: 'boto3', status: 'auto_denied' }); + expect(auto).toBeTruthy(); + // pending + other-job requests are excluded + repo.recordPackageRequest({ taskId: '1', jobId: 'jA', spec: 'numpy', normalizedName: 'numpy' }); + repo.recordPackageRequest({ taskId: '1', jobId: 'jB', spec: 'flask', normalizedName: 'flask', status: 'denied' }); + const names = repo.listDeclinedPackageNamesByJob('jA').sort(); + expect(names).toEqual(['boto3', 'httpx']); + }); + + it('resumePackageRequestJob only re-queues a job parked for package_request', () => { + // No such job → 0 changes (safe no-op). + expect(repo.resumePackageRequestJob('missing-job')).toBe(0); + }); +}); diff --git a/src/db/repository.transcript-only-io.test.ts b/src/db/repository.transcript-only-io.test.ts new file mode 100644 index 0000000..e44d924 --- /dev/null +++ b/src/db/repository.transcript-only-io.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from './repository.js'; + +let tempDir = ''; +function makeRepo(): Repository { + return new Repository(join(tempDir, 'orchestrator.db')); +} + +beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-tconly-')); }); +afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); + +describe('makeTranscriptOnlyConversationIO', () => { + it('yields empty comments and passes the transcript path through', async () => { + const repo = makeRepo(); + const io = repo.makeTranscriptOnlyConversationIO('/tmp/does-not-matter/transcript.jsonl'); + expect(await io.listComments()).toEqual([]); + expect(io.transcriptPath).toBe('/tmp/does-not-matter/transcript.jsonl'); + }); + + it('accepts an undefined transcript path (comments still empty)', async () => { + const repo = makeRepo(); + const io = repo.makeTranscriptOnlyConversationIO(undefined); + expect(await io.listComments()).toEqual([]); + expect(io.transcriptPath).toBeUndefined(); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index 1fd5495..e2b6ae7 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -1,1337 +1,51 @@ import Database from 'better-sqlite3'; -import { readFileSync, rmSync, existsSync, realpathSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join, normalize, sep } from 'path'; -import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto'; -import { v4 as uuidv4 } from 'uuid'; import { logger } from '../logger.js'; -import { buildVisibilityWhere, buildSpaceVisibilityWhere } from '../bridge/visibility.js'; -import { spaceColor } from '../spaces/space-color.js'; -import { buildTitleFromGoal } from '../title-generation.js'; -import { ensureTaskCommentFts } from './migrate.js'; -import { normalizeCommentToIndexText } from '../engine/task-index/normalize.js'; +import * as schemaRepo from './repositories/schema.js'; +import * as jobsRepo from './repositories/jobs.js'; +import { JobStatus, Job, CreateJobParams, SubtaskInfo } from './repositories/jobs.js'; +import * as spacesRepo from './repositories/spaces.js'; +import { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams } from './repositories/spaces.js'; +import * as usersRepo from './repositories/users.js'; +import { User, CreateUserParams, FindOrCreateByOAuthParams, CreateLocalUserParams, LocalOrg, LocalOrgMember, GiteaOrgInput, GiteaOrg } from './repositories/users.js'; +import * as workerNodesRepo from './repositories/worker-nodes.js'; +import { WorkerNode, UpsertWorkerNodeParams } from './repositories/worker-nodes.js'; +import * as scheduledTasksRepo from './repositories/scheduled-tasks.js'; +import { ScheduledTask, CreateScheduledTaskParams, UpdateScheduledTaskParams } from './repositories/scheduled-tasks.js'; +import * as a2aRepo from './repositories/a2a.js'; +import { A2aClientRow, A2aDelegationRow } from './repositories/a2a.js'; +import * as reflectionRepo from './repositories/reflection.js'; +import * as toolRequestsRepo from './repositories/tool-requests.js'; +import { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js'; +import * as gatewayRepo from './repositories/gateway.js'; +import { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js'; +import * as llmUsageRepo from './repositories/llm-usage.js'; +import { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; +import * as notificationsRepo from './repositories/notifications.js'; +import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; +import * as auditRepo from './repositories/audit.js'; +import * as appSharesRepo from './repositories/app-shares.js'; +import * as provenanceRepo from './repositories/provenance.js'; +import * as localTasksRepo from './repositories/local-tasks.js'; +import { LocalTask, MissionBrief, LocalTaskComment, CreateLocalTaskParams } from './repositories/local-tasks.js'; +export { MISSION_BRIEF_FIELDS } from './repositories/local-tasks.js'; +export type { TitleSource, LocalTask, MissionBriefField, MissionBrief, LocalTaskComment, CreateLocalTaskParams } from './repositories/local-tasks.js'; +import * as taskSearchRepo from './repositories/task-search.js'; +import * as calendarRepo from './repositories/calendar.js'; +import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMonth } from './repositories/calendar.js'; +export { enumerateLocalDays } from './repositories/calendar.js'; +export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js'; +export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; +export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; +export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js'; +export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js'; +export type { A2aClientRow, A2aDelegationRow } from './repositories/a2a.js'; +export type { ScheduledTaskKind, ScheduledTask, CreateScheduledTaskParams, UpdateScheduledTaskParams } from './repositories/scheduled-tasks.js'; +export type { WorkerNode, UpsertWorkerNodeParams } from './repositories/worker-nodes.js'; +export type { User, CreateUserParams, FindOrCreateByOAuthParams, CreateLocalUserParams, LocalOrg, LocalOrgMember, GiteaOrgInput, GiteaOrg } from './repositories/users.js'; +export type { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams } from './repositories/spaces.js'; +export type { JobStatus, JobRole, JobProfile, TaskClass, Job, CreateJobParams, SubtaskInfo } from './repositories/jobs.js'; +export { localTaskRepoName } from './repositories/shared.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -/** - * Shared SQL fragments for LocalTask read queries. - * - * getLocalTask / listLocalTasks / getLocalTaskByShareToken all need to expose - * the owner's display name and the org display name of `visibility_scope_org_id`. - * These constants keep the three queries in sync. - * - * Usage: splice into the SELECT list and the FROM-clause joins, e.g. - * - * SELECT lt.*, ${LOCAL_TASK_DISPLAY_SELECT} - * FROM local_tasks lt - * ${LOCAL_TASK_DISPLAY_JOIN} - * WHERE ... - * - * A correlated subquery (`MIN(org_name)`) is used instead of a JOIN because - * user_gitea_orgs is keyed per-user, and we only need any one display name - * for the org id — this avoids row-multiplication across the join. - */ -const LOCAL_TASK_DISPLAY_SELECT = ` - u.name AS owner_name, - COALESCE( - (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = lt.visibility_scope_org_id), - (SELECT name FROM local_orgs WHERE id = lt.visibility_scope_org_id) - ) AS visibility_scope_org_name -`.trim(); -const LOCAL_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = lt.owner_id`; - -const SCHEDULED_TASK_DISPLAY_SELECT = ` - u.name AS owner_name, - COALESCE( - (SELECT MIN(org_name) FROM user_gitea_orgs WHERE org_id = st.visibility_scope_org_id), - (SELECT name FROM local_orgs WHERE id = st.visibility_scope_org_id) - ) AS visibility_scope_org_name -`.trim(); -const SCHEDULED_TASK_DISPLAY_JOIN = `LEFT JOIN users u ON u.id = st.owner_id`; - -export type JobStatus = - | 'queued' - | 'dispatching' - | 'running' - | 'succeeded' - | 'failed' - | 'retry' - | 'cancelled' - | 'waiting_human' - | 'waiting_subtasks'; - -export type JobRole = 'auto' | 'fast' | 'quality' | 'reflection'; -/** @deprecated Use JobRole instead */ -export type JobProfile = JobRole; -/** @deprecated Removed — taskClass is no longer a separate concept */ -export type TaskClass = 'auto' | 'low_level' | 'high_level'; - -export interface Job { - id: string; - repo: string; - issueNumber: number; - prNumber: number | null; - status: JobStatus; - pieceName: string; - currentMovement: string | null; - currentActivity: string | null; - instruction: string; - branchName: string | null; - worktreePath: string | null; - attempt: number; - maxAttempts: number; - nextRetryAt: string | null; - errorSummary: string | null; - abortReason: string | null; - resumeMovement: string | null; - waitReason: string | null; - askCount: number; - workerId: string | null; - /** - * Physical backend id (e.g. LiteLLM deployment name) that handled - * this job's LLM calls when running through a proxy worker. Set on - * the first LLM call and never overwritten — sticky-backend policy - * per design Open Question #3. NULL for direct workers and for jobs - * that haven't issued any proxied LLM call yet. - */ - lastBackendId: string | null; - parentJobId: string | null; - continuedFromJobId: string | null; - subtaskDepth: number; - requiredRole: JobRole; - /** @deprecated Use requiredRole */ - requiredProfile: JobRole; - ownerId: string | null; - visibility: 'private' | 'org' | 'public'; - visibilityScopeOrgId: string | null; - contextPromptTokens: number | null; - contextLimitTokens: number | null; - contextUpdatedAt: string | null; - browserSessionProfileId?: number | null; - taskKind: 'agent' | 'reflection'; - payload: string | null; - /** 所属スペース。親タスク/親ジョブから継承される(Task 5 で設定)。null は個人スペース。 */ - spaceId: string | null; - /** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。サブタスクが親から継承する。 */ - runtimeDir: string | null; - createdAt: string; - updatedAt: string; -} - -export interface CreateJobParams { - repo: string; - issueNumber: number; - instruction: string; - pieceName?: string; - maxAttempts?: number; - resumeMovement?: string | null; - askCount?: number; - role?: JobRole; - /** @deprecated Use role instead */ - profile?: JobRole; - parentJobId?: string | null; - continuedFromJobId?: string | null; - subtaskDepth?: number; - ownerId?: string | null; - visibility?: 'private' | 'org' | 'public'; - visibilityScopeOrgId?: string | null; - browserSessionProfileId?: number | null; - taskKind?: 'agent' | 'reflection'; - payload?: string; - /** 所属スペース(spec §5.7 親→子継承)。NULL は owner 個人スペースに解決。 */ - spaceId?: string | null; - /** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */ - runtimeDir?: string | null; -} - -export interface SubtaskInfo { - id: string; - issueNumber: number; - status: JobStatus; - instruction: string; - worktreePath: string | null; - createdAt: string; - updatedAt: string; - children?: SubtaskInfo[]; - childCount?: number; - childCompleted?: number; -} - -export type TitleSource = 'auto' | 'agent' | 'user'; - -export interface LocalTask { - id: number; - title: string; - /** Provenance of `title`. 'user' is never auto-overwritten by the agent. */ - titleSource: TitleSource; - body: string; - pieceName: string; - profile: 'auto' | 'fast' | 'quality' | string; - outputFormat: 'text' | 'markdown' | 'json' | string; - askPolicy: 'low' | 'high' | string; - priority: 'low' | 'medium' | 'high' | string; - state: 'open' | 'closed' | string; - workspacePath: string | null; - /** 実行先ワークスペースの種類。persistent=スペース共有ツリー / ephemeral=使い捨て。 */ - workspaceMode: 'persistent' | 'ephemeral'; - ownerId: string | null; - ownerName?: string | null; - visibility: 'private' | 'org' | 'public'; - visibilityScopeOrgId: string | null; - visibilityScopeOrgName?: string | null; - /** 所属スペース。null は owner の個人スペース(=現行の data/users/{owner})に解決される。 */ - spaceId: string | null; - /** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */ - runtimeDir: string | null; - createdAt: string; - updatedAt: string; - feedbackRating: 'good' | 'bad' | null; - feedbackTags: string[] | null; - feedbackComment: string | null; - feedbackAt: string | null; - shareToken: string | null; - sharedAt: string | null; - browserSessionProfileId?: number | null; - /** - * Mission Brief: per-task pinned memo. Carries goal / done / open / - * clarifications. Always rendered at the top of every movement's - * system prompt. The LLM updates it via mission_update; the user - * edits it from the Overview tab. - */ - missionBrief: MissionBrief | null; - /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). */ - options: Record; - latestJob?: Job | null; - subtasks?: SubtaskInfo[]; - subtaskCount?: number; - subtaskCompleted?: number; -} - -/** - * Allowed widget kinds. 'markdown' is the original Side Info Panel widget - * (PR #308). 'node-status' was added in Phase B (2026-05) and ignores - * markdown_content — it renders BackendStatusRegistry data live. The union - * intentionally lives here so the API, tools and UI all share a single - * source of truth. - */ -/** - * スペース・カレンダーの予定エントリ(passive。実行はしない)。 - * spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §データモデル - */ -export interface CalendarEvent { - id: number; - spaceId: string; - ownerId: string | null; - date: string; // 開始日 YYYY-MM-DD(ローカル日付) - endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日) - time: string | null; // 開始 HH:MM。null = 終日 - endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) - title: string; - description: string | null; - createdBy: 'user' | 'agent'; - sourceTaskId: number | null; - createdAt: string; - updatedAt: string; -} - -interface CalendarEventRow { - id: number; - space_id: string; - owner_id: string | null; - date: string; - end_date: string | null; - time: string | null; - end_time: string | null; - title: string; - description: string | null; - created_by: string; - source_task_id: number | null; - created_at: string; - updated_at: string; -} - -/** - * [start, end](両端含む)のローカル暦日を 'YYYY-MM-DD' 配列で返す。 - * end < start は空配列。複数日イベントを各日に展開する集計で使う。 - */ -export function enumerateLocalDays(start: string, end: string): string[] { - if (end < start) return []; - const out: string[] = []; - const [ys, ms, ds] = start.split('-').map(Number); - const [ye, me, de] = end.split('-').map(Number); - let cur = Date.UTC(ys, ms - 1, ds); - const last = Date.UTC(ye, me - 1, de); - // 安全弁: 異常な範囲で無限ループしないよう上限を設ける(最大 ~2 年)。 - for (let i = 0; cur <= last && i < 800; i++) { - const d = new Date(cur); - out.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`); - cur += 86_400_000; - } - return out; -} - -function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent { - return { - id: row.id, - spaceId: row.space_id, - ownerId: row.owner_id ?? null, - date: row.date, - endDate: row.end_date ?? null, - time: row.time ?? null, - endTime: row.end_time ?? null, - title: row.title, - description: row.description ?? null, - createdBy: row.created_by === 'agent' ? 'agent' : 'user', - sourceTaskId: row.source_task_id ?? null, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -/** カレンダー月ビューの1日分の集計カウント。 */ -export interface CalendarDayCounts { - taskCount: number; - eventCount: number; -} - -export type ToolRequestCategory = 'requested' | 'blocked' | 'unknown'; -export type ToolRequestStatus = 'pending' | 'approved' | 'denied' | 'auto_denied'; - -/** 現在の movement で使えないツールの要求記録(能動 RequestTool / 受動 block 捕捉)。 */ -export interface ToolRequest { - id: string; - taskId: string | null; - jobId: string | null; - spaceId: string | null; - pieceName: string; - movementName: string; - toolName: string; - reason: string | null; - category: ToolRequestCategory; - status: ToolRequestStatus; - grantScope: 'task' | 'piece' | null; - decidedBy: string | null; - createdAt: string; - decidedAt: string | null; -} - -interface ToolRequestRow { - id: string; - task_id: string | null; - job_id: string | null; - space_id: string | null; - piece_name: string; - movement_name: string; - tool_name: string; - reason: string | null; - category: string; - status: string; - grant_scope: string | null; - decided_by: string | null; - created_at: string; - decided_at: string | null; -} - -export interface A2aClientRow { - clientId: string; - name: string; - redirectUris: string[]; - secretHash: string | null; - tokenEndpointAuthMethod: string; - agentCardUrl: string | null; - issuer: string | null; - keyFingerprint: string | null; - status: string; - createdBy: string | null; - createdAt?: string; -} - -export interface A2aDelegationRow { - id: string; - userId: string; - clientId: string; - grantId: string | null; - grantedSpaceIds: string[]; - grantedSkills: string[]; - audience: string | null; - expiresAt: string | null; - revokedAt: string | null; - createdAt?: string; -} - -function rowToToolRequest(row: ToolRequestRow): ToolRequest { - return { - id: row.id, - taskId: row.task_id ?? null, - jobId: row.job_id ?? null, - spaceId: row.space_id ?? null, - pieceName: row.piece_name, - movementName: row.movement_name, - toolName: row.tool_name, - reason: row.reason ?? null, - category: (row.category as ToolRequestCategory) ?? 'requested', - status: (row.status as ToolRequestStatus) ?? 'pending', - grantScope: (row.grant_scope as 'task' | 'piece' | null) ?? null, - decidedBy: row.decided_by ?? null, - createdAt: row.created_at, - decidedAt: row.decided_at ?? null, - }; -} - -/** DB row shape for workspace_file_provenance. */ -interface WorkspaceFileProvenanceRow { - space_id: string | null; - workspace_path: string; - rel_path: string; - created_by_task_id: number | null; - created_by_job_id: string | null; - created_by_piece: string | null; - created_by_movement: string | null; - source_kind: string; - first_seen_at: string | null; - last_modified_by_task_id: number | null; - last_modified_by_job_id: string | null; - last_modified_at: string | null; - checksum: string | null; - note: string | null; -} - -function rowToFileProvenance( - row: WorkspaceFileProvenanceRow, -): import('../engine/tools/core.js').FileProvenanceRecord { - return { - relPath: row.rel_path, - sourceKind: (row.source_kind as import('../engine/tools/core.js').FileProvenanceSourceKind) ?? 'unknown', - createdByTaskId: row.created_by_task_id ?? null, - createdByJobId: row.created_by_job_id ?? null, - createdByPiece: row.created_by_piece ?? null, - createdByMovement: row.created_by_movement ?? null, - firstSeenAt: row.first_seen_at ?? null, - lastModifiedByTaskId: row.last_modified_by_task_id ?? null, - lastModifiedByJobId: row.last_modified_by_job_id ?? null, - lastModifiedAt: row.last_modified_at ?? null, - checksum: row.checksum ?? null, - note: row.note ?? null, - }; -} - -/** Escape LIKE wildcards so a caller-supplied prefix matches literally. */ -function escapeLike(s: string): string { - return s.replace(/[\\%_]/g, (c) => '\\' + c); -} - -/** ピース集計の1行(tool_name×category ごとの件数)。 */ -export interface ToolRequestAggregate { - toolName: string; - category: ToolRequestCategory; - count: number; - lastSeen: string; - sampleReason: string | null; -} - -/** カレンダー日詳細のタスク行(軽量・最新 job 状態付き)。 */ -export interface CalendarDayTask { - id: number; - title: string; - state: string; - status: string | null; // 最新 job の status(無ければ null) - createdAt: string; -} - -/** 横断カレンダーの凡例 1 行(スペースとその表示色)。 */ -export interface CrossCalendarSpace { - id: string; - name: string; - color: string; // brand_color、無ければ spaceColor(id) で決定論的に導出 -} - -/** 横断カレンダー月ビュー。`days[date][spaceId]` = そのスペースのその日の集計。 */ -export interface CrossSpaceCalendarMonth { - days: Record>; - events: CalendarEvent[]; // 各イベントは spaceId を持つ(CalendarEvent.spaceId) - spaces: CrossCalendarSpace[]; -} - -/** - * Ordered Mission Brief field list. Single source of truth for parse / update - * / API whitelist / MissionUpdate tool so a future field is a one-line - * addition here (plus its typed interface member). Legacy fields come first so - * JSON key order and title-derivation (goal only) stay unchanged. - */ -export const MISSION_BRIEF_FIELDS = [ - 'goal', - 'done', - 'open', - 'clarifications', - 'user_constraints', - 'decisions', - 'current_focus', -] as const; - -export type MissionBriefField = (typeof MISSION_BRIEF_FIELDS)[number]; - -export interface MissionBrief { - goal: string; - done: string; - open: string; - clarifications: string; - /** Durable user-stated constraints ("do not change X"). */ - user_constraints?: string; - /** Decisions made after clarification, with rationale. */ - decisions?: string; - /** What this movement is actively working on right now. */ - current_focus?: string; -} - -// ── AAO Gateway Phase 2a: virtual keys ───────────────────────────────── -// Stored shape returned by all gateway-key repository methods. The raw -// bearer is NEVER persisted or returned — only `keyHash` (sha256) lives -// in the DB, and `keyPrefix` is the human-readable head used in admin UI -// lists (`sk-aao-XXXXXX`). The raw key surfaces exactly once, from the -// admin API on issue / rotate; see src/bridge/admin-gateway-api.ts. - -export type GatewayVirtualKeySource = 'admin' | 'config-import'; - -export interface GatewayVirtualKey { - id: string; - keyHash: string; - keyPrefix: string; - team: string; - /** Null = no per-key allowlist (any backend.model is accepted). */ - allowedModels: string[] | null; - source: GatewayVirtualKeySource; - createdAt: string; - createdBy: string | null; - /** ISO timestamp if revoked; null while active. */ - revokedAt: string | null; - revokedBy: string | null; - lastUsedAt: string | null; - /** - * Phase 2b: monthly tokens budget. NULL = unlimited. When set, the - * gateway rejects requests with 402 once the current UTC month - * `tokens_in + tokens_out` reaches this number (post-hoc enforcement — - * the offending request that pushes the counter over the limit still - * completes; the next one is rejected). - */ - tokensBudget: number | null; - /** - * Phase 2b: per-key requests-per-minute cap. NULL = unlimited. Enforced - * as a sliding 60-second window in-process; multi-instance setups are - * intentionally NOT synchronized (Phase 3 if needed). - */ - rateLimitRpm: number | null; -} - -interface GatewayVirtualKeyRow { - id: string; - key_hash: string; - key_prefix: string; - team: string; - allowed_models: string | null; - source: string; - created_at: string; - created_by: string | null; - revoked_at: string | null; - revoked_by: string | null; - last_used_at: string | null; - tokens_budget: number | null; - rate_limit_rpm: number | null; -} - -// Phase 2b: monthly usage counter per virtual key. -export interface GatewayKeyUsage { - keyId: string; - /** UTC month bucket as 'YYYY-MM' — see src/gateway/period.ts. */ - periodStart: string; - tokensIn: number; - tokensOut: number; - requests: number; - lastUpdatedAt: string; -} - -interface GatewayKeyUsageRow { - key_id: string; - period_start: string; - tokens_in: number; - tokens_out: number; - requests: number; - last_updated_at: string; -} - -function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage { - return { - keyId: row.key_id, - periodStart: row.period_start, - tokensIn: row.tokens_in, - tokensOut: row.tokens_out, - requests: row.requests, - lastUpdatedAt: row.last_updated_at, - }; -} - -/** Per-call delta for the per-user daily LLM usage ledger. */ -export interface LlmUsageIncrement { - /** UTC day bucket 'YYYY-MM-DD'. Defaults to today (UTC) when omitted. */ - day?: string; - /** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */ - userId: string; - source: 'gateway' | 'direct'; - /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ - model: string; - /** Backend server name (gateway backendId / direct host), or 'unknown'. */ - route: string; - tokensIn?: number; - tokensOut?: number; - requests?: number; - at?: string; -} - -/** Daily-grouped aggregate row (model/route collapsed) for the usage API. */ -export interface LlmUsageDailyAgg { - day: string; - userId: string; - source: string; - tokensIn: number; - tokensOut: number; - requests: number; -} - -/** Hour-grain UPSERT input for the v2 usage ledger. */ -export interface LlmUsageHourlyIncrement { - /** UTC hour bucket 'YYYY-MM-DDTHH'. Defaults to the current hour (UTC). */ - hour?: string; - /** Owner id, 'local' / 'system' sentinel, or 'gw:' for downstream gateway consumers. */ - userId: string; - /** - * 'direct' / 'gateway' are this orchestrator's own calls. 'gateway_downstream' - * is traffic the AAO Gateway proxied for an EXTERNAL consumer (recorded from - * the gateway proxy path, keyed by team) — kept distinct so it never merges - * with our own usage. See docs/superpowers/specs/2026-06-19-usage-hourly-profile-and-gateway-downstream-design.md - */ - source: 'gateway' | 'direct' | 'gateway_downstream'; - /** Real model name (chunk.model), routing-key fallback, or 'unknown'. */ - model: string; - /** Backend server name (gateway backendId / direct host), or 'unknown'. */ - route: string; - tokensIn?: number; - tokensOut?: number; - requests?: number; - /** ISO timestamp for last_updated_at + hour default. Defaults to now. */ - at?: string; -} - -/** - * Raw hour-grain ledger row (no axis collapsed) for the usage API. The API - * re-buckets `hour` into the viewer's local calendar period and groups by - * whichever of source/model/route/user/org the request asked for. - */ -export interface LlmUsageHourlyRow { - hour: string; - userId: string; - source: string; - model: string; - route: string; - tokensIn: number; - tokensOut: number; - requests: number; -} - -/** - * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either - * a positive integer or null. Anything else (undefined, null, 0, - * negative, NaN, non-number) collapses to null = "no limit" so callers - * can't accidentally persist a value that would silently block all - * traffic. - */ -function normalizeOptionalPositiveInt(v: unknown): number | null { - if (v === undefined || v === null) return null; - if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return null; - return Math.floor(v); -} - -function rowToGatewayVirtualKey(row: GatewayVirtualKeyRow): GatewayVirtualKey { - let allowedModels: string[] | null = null; - if (row.allowed_models !== null && row.allowed_models !== '') { - try { - const parsed: unknown = JSON.parse(row.allowed_models); - if (Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) { - allowedModels = parsed; - } - } catch { - // Corrupt JSON: treat as "no allowlist" (safer than fail-open - // because routing has its own backend.model gate; this is just - // the per-key filter on top). - allowedModels = null; - } - } - const source: GatewayVirtualKeySource = - row.source === 'config-import' ? 'config-import' : 'admin'; - // tokens_budget / rate_limit_rpm may legitimately arrive as null (no - // limit). Coerce non-positive integers to null defensively because the - // gateway middleware treats null as "unlimited" — a corrupt `0` would - // otherwise silently block every request. - const tokensBudget = - typeof row.tokens_budget === 'number' && Number.isFinite(row.tokens_budget) && row.tokens_budget > 0 - ? Math.floor(row.tokens_budget) - : null; - const rateLimitRpm = - typeof row.rate_limit_rpm === 'number' && Number.isFinite(row.rate_limit_rpm) && row.rate_limit_rpm > 0 - ? Math.floor(row.rate_limit_rpm) - : null; - return { - id: row.id, - keyHash: row.key_hash, - keyPrefix: row.key_prefix, - team: row.team, - allowedModels, - source, - createdAt: row.created_at, - createdBy: row.created_by, - revokedAt: row.revoked_at, - revokedBy: row.revoked_by, - lastUsedAt: row.last_used_at, - tokensBudget, - rateLimitRpm, - }; -} - -export interface LocalTaskComment { - id: number; - taskId: number; - author: string; - kind: string; - body: string; - /** Filenames saved to the task's input/ dir for this comment (UI download links). */ - attachments: string[]; - createdAt: string; - injectedAt: string | null; -} - -export interface WorkerNode { - workerId: string; - endpoint: string; - enabled: boolean; - healthy: boolean; - roles: string[]; - availableModels: string[]; - inflightJobs: number; - maxConcurrency: number; - lastError: string | null; - lastSeenAt: string; - updatedAt: string; -} - -export interface UpsertWorkerNodeParams { - workerId: string; - endpoint: string; - enabled: boolean; - healthy: boolean; - roles: string[]; - availableModels?: string[]; - inflightJobs?: number; - maxConcurrency?: number; - lastError?: string | null; -} - -export interface CreateLocalTaskParams { - title: string; - /** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */ - titleSource?: TitleSource; - body: string; - pieceName?: string; - profile?: 'auto' | 'fast' | 'quality'; - outputFormat?: 'text' | 'markdown' | 'json'; - askPolicy?: 'low' | 'high'; - priority?: 'low' | 'medium' | 'high'; - workspacePath?: string | null; - /** ワークスペースの種類。未指定は 'persistent'(スペース共有)。 */ - workspaceMode?: 'persistent' | 'ephemeral'; - ownerId?: string | null; - visibility?: 'private' | 'org' | 'public'; - visibilityScopeOrgId?: string | null; - browserSessionProfileId?: number | null; - /** 所属スペース。未指定(null)は owner の個人スペースに解決される。 */ - spaceId?: string | null; - /** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */ - options?: Record; -} - -// ── Browser Notifications V2 (Web Push) ────────────────────────────── -// Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md - -export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; - -export interface PushSubscriptionRecord { - id: string; - userId: string; - endpoint: string; - p256dh: string; - auth: string; - userAgent: string | null; - vapidKeyId: string; - createdAt: string; - lastSuccessAt: string | null; - lastFailureAt: string | null; - failureCount: number; -} - -export interface UpsertPushSubscriptionInput { - userId: string; - endpoint: string; - p256dh: string; - auth: string; - userAgent?: string | null; - vapidKeyId: string; -} - -export interface NotificationPrefs { - userId: string; - enabled: boolean; - events: Record; - includeDetails: boolean; - v1Migrated: boolean; - updatedAt: string; -} - -export type NotificationPrefsUpdate = Partial>; - -export type ScheduledTaskKind = 'agent' | 'script'; - -export interface ScheduledTask { - id: number; - title: string | null; - body: string; - pieceName: string; - profile: string; - outputFormat: string; - cronExpression: string; - nextRunAt: string; - lastRunAt: string | null; - lastJobId: string | null; - isActive: boolean; - ownerId: string | null; - ownerName?: string | null; - visibility: 'private' | 'org' | 'public'; - visibilityScopeOrgId: string | null; - visibilityScopeOrgName?: string | null; - browserSessionProfileId?: number | null; - taskKind: ScheduledTaskKind; - scriptName: string | null; - scriptParams: string | null; // JSON-encoded object or null - /** 所属スペース。生成される local_task に継承される。null = owner 個人スペース。 */ - spaceId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface CreateScheduledTaskParams { - title?: string | null; - body: string; - pieceName?: string; - profile?: string; - outputFormat?: string; - cronExpression: string; - nextRunAt: string; - ownerId?: string | null; - visibility?: 'private' | 'org' | 'public'; - visibilityScopeOrgId?: string | null; - browserSessionProfileId?: number | null; - taskKind?: ScheduledTaskKind; - scriptName?: string | null; - scriptParams?: string | null; - spaceId?: string | null; -} - -export interface UpdateScheduledTaskParams { - title?: string; - body?: string; - pieceName?: string; - profile?: string; - outputFormat?: string; - cronExpression?: string; - nextRunAt?: string; - lastRunAt?: string; - lastJobId?: string; - isActive?: boolean; - visibility?: 'private' | 'org' | 'public'; - visibilityScopeOrgId?: string | null; - browserSessionProfileId?: number | null; - taskKind?: ScheduledTaskKind; - scriptName?: string | null; - scriptParams?: string | null; -} - -export interface User { - id: string; - email: string; - name: string | null; - avatarUrl: string | null; - role: 'admin' | 'user'; - status: 'active' | 'pending' | 'disabled'; - defaultVisibility: 'private' | 'org' | 'public'; - defaultVisibilityOrgId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface CreateUserParams { - email: string; - name: string; - role: 'admin' | 'user'; - status: 'active' | 'pending' | 'disabled'; - avatarUrl?: string; -} - -export interface FindOrCreateByOAuthParams { - provider: string; - providerId: string; - email: string; - name: string; - avatarUrl?: string; -} - -export interface CreateLocalUserParams { - email: string; - password: string; - role: 'admin' | 'user'; - status: 'active' | 'pending' | 'disabled'; - name?: string; -} - -export interface LocalOrg { - id: string; - name: string; - createdBy: string | null; - createdAt: string; -} - -export interface LocalOrgMember { - userId: string; - role: string; -} - -export interface GiteaOrgInput { - orgId: string; - orgName: string; -} - -export interface GiteaOrg extends GiteaOrgInput { - fetchedAt: string; -} - -interface JobRow { - id: string; - repo: string; - issue_number: number; - pr_number: number | null; - status: string; - piece_name: string; - current_movement: string | null; - current_activity: string | null; - instruction: string; - branch_name: string | null; - worktree_path: string | null; - attempt: number; - max_attempts: number; - next_retry_at: string | null; - error_summary: string | null; - abort_reason: string | null; - resume_movement: string | null; - wait_reason: string | null; - ask_count: number; - worker_id: string | null; - last_backend_id: string | null; - parent_job_id: string | null; - continued_from_job_id: string | null; - subtask_depth: number; - required_profile: string; - task_class: string; - owner_id: string | null; - visibility: string | null; - visibility_scope_org_id: string | null; - context_prompt_tokens: number | null; - context_limit_tokens: number | null; - context_updated_at: string | null; - browser_session_profile_id: number | null; - task_kind: string; - payload: string | null; - space_id: string | null; - runtime_dir: string | null; - created_at: string; - updated_at: string; -} - -interface LocalTaskRow { - id: number; - title: string; - title_source: string | null; - body: string; - piece_name: string; - profile: string; - output_format: string; - ask_policy: string; - priority: string; - state: string; - workspace_path: string | null; - workspace_mode: string | null; - runtime_dir: string | null; - owner_id: string | null; - owner_name?: string | null; - visibility: string | null; - visibility_scope_org_id: string | null; - visibility_scope_org_name?: string | null; - space_id: string | null; - created_at: string; - updated_at: string; - feedback_rating: string | null; - feedback_comment: string | null; - feedback_tags: string | null; - feedback_at: string | null; - share_token: string | null; - shared_at: string | null; - mission_brief: string | null; - browser_session_profile_id: number | null; - options: string | null; -} - -interface SpaceRow { - id: string; - kind: string; - title: string; - description: string; - owner_id: string | null; - visibility: string; - visibility_scope_org_id: string | null; - status: string; - brand_color: string | null; - workspace_dir: string | null; - created_at: string; - updated_at: string; -} - -export interface Space { - id: string; - kind: 'personal' | 'case'; - title: string; - description: string; - ownerId: string | null; - visibility: 'private' | 'org' | 'public'; - visibilityScopeOrgId: string | null; - status: 'open' | 'archived'; - brandColor: string | null; - workspaceDir: string | null; - createdAt: string; - updatedAt: string; -} - -export type SpaceMemberRoleValue = 'owner' | 'editor' | 'viewer'; - -/** space_members の 1 行 + users から合成した表示情報。owner_id は別途 owner として扱う。 */ -export interface SpaceMember { - spaceId: string; - userId: string; - role: SpaceMemberRoleValue; - invitedBy: string | null; - createdAt: string; - /** users JOIN 由来の表示用フィールド(行が無い場合 null)。 */ - name: string | null; - email: string | null; - avatarUrl: string | null; -} - -/** 招待リンクで付与できるロール。owner は付与不可(権限昇格防止)。 */ -export type SpaceInviteRole = 'editor' | 'viewer'; - -/** space_invites の 1 行。 */ -export interface SpaceInvite { - token: string; - spaceId: string; - role: SpaceInviteRole; - createdBy: string | null; - createdAt: string; - /** ISO 文字列。null = 無期限。 */ - expiresAt: string | null; - /** ISO 文字列。null = 有効。 */ - revokedAt: string | null; -} - -export interface CreateSpaceParams { - id?: string; - kind: 'personal' | 'case'; - title: string; - description?: string; - ownerId: string | null; - visibility?: 'private' | 'org' | 'public'; - visibilityScopeOrgId?: string | null; - brandColor?: string | null; - workspaceDir?: string | null; -} - -interface LocalTaskCommentRow { - id: number; - task_id: number; - author: string; - kind: string; - body: string; - attachments: string | null; - created_at: string; - injected_at: string | null; -} - -interface UserRow { - id: string; - email: string; - name: string | null; - avatar_url: string | null; - role: string; - status: string; - default_visibility: string | null; - default_visibility_org_id: string | null; - created_at: string; - updated_at: string; -} - -interface WorkerNodeRow { - worker_id: string; - endpoint: string; - enabled: number; - healthy: number; - profile_tags: string; - task_class_tags: string; - available_models: string | null; - inflight_jobs: number; - max_concurrency: number; - last_error: string | null; - last_seen_at: string; - updated_at: string; -} - -function isJobRole(value: string): value is JobRole { - return value === 'auto' || value === 'fast' || value === 'quality' || value === 'reflection'; -} - -function normalizeJobRole(value: string | undefined): JobRole { - return value && isJobRole(value) ? value : 'auto'; -} - -function encodeTags(values: string[]): string { - const unique = Array.from(new Set(values.filter(Boolean))); - return `,${unique.join(',')},`; -} - -function decodeTags(raw: string | null): string[] { - if (!raw) return []; - return raw.split(',').map((value) => value.trim()).filter(Boolean); -} - -function decodeAvailableModels(raw: string | null): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw) as unknown; - return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []; - } catch { - return []; - } -} - -function deriveJobRole(instruction: string, explicitRole?: JobRole): JobRole { - if (explicitRole) return explicitRole; - const match = /ui_profile:\s*(auto|fast|quality)/i.exec(instruction); - return normalizeJobRole(match?.[1]?.toLowerCase()); -} - -/** SQLite datetime('now') は UTC だがタイムゾーン情報なし。'Z' を付加して ISO 8601 UTC として明示する */ -function utc(dt: string | null): string { - if (!dt) return ''; - // 既に Z や +/- オフセットが付いていれば何もしない - if (/[Zz]$/.test(dt) || /[+-]\d{2}:\d{2}$/.test(dt)) return dt; - return dt.replace(' ', 'T') + 'Z'; -} - -function rowToJob(row: JobRow): Job { - return { - id: row.id, - repo: row.repo, - issueNumber: row.issue_number, - prNumber: row.pr_number, - status: row.status as JobStatus, - pieceName: row.piece_name, - currentMovement: row.current_movement, - currentActivity: row.current_activity, - instruction: row.instruction, - branchName: row.branch_name, - worktreePath: row.worktree_path, - attempt: row.attempt, - maxAttempts: row.max_attempts, - nextRetryAt: utc(row.next_retry_at), - errorSummary: row.error_summary, - abortReason: row.abort_reason ?? null, - resumeMovement: row.resume_movement, - waitReason: row.wait_reason ?? null, - askCount: row.ask_count, - workerId: row.worker_id, - lastBackendId: row.last_backend_id ?? null, - parentJobId: row.parent_job_id, - continuedFromJobId: row.continued_from_job_id ?? null, - subtaskDepth: row.subtask_depth ?? 0, - requiredRole: normalizeJobRole(row.required_profile), - requiredProfile: normalizeJobRole(row.required_profile), - ownerId: row.owner_id ?? null, - visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'), - visibilityScopeOrgId: row.visibility_scope_org_id ?? null, - contextPromptTokens: row.context_prompt_tokens, - contextLimitTokens: row.context_limit_tokens, - contextUpdatedAt: row.context_updated_at ? utc(row.context_updated_at) : null, - browserSessionProfileId: row.browser_session_profile_id ?? null, - taskKind: row.task_kind === 'reflection' ? 'reflection' : 'agent', - payload: row.payload, - spaceId: row.space_id ?? null, - runtimeDir: row.runtime_dir ?? null, - createdAt: utc(row.created_at), - updatedAt: utc(row.updated_at), - }; -} - -function rowToLocalTask(row: LocalTaskRow): LocalTask { - return { - id: row.id, - title: row.title, - titleSource: (row.title_source as TitleSource | null) ?? 'auto', - body: row.body, - pieceName: row.piece_name, - profile: row.profile, - outputFormat: row.output_format, - askPolicy: row.ask_policy, - priority: row.priority, - state: row.state, - workspacePath: row.workspace_path, - workspaceMode: row.workspace_mode === 'ephemeral' ? 'ephemeral' : 'persistent', - ownerId: row.owner_id ?? null, - ownerName: row.owner_name ?? null, - visibility: (row.visibility ?? 'private') as LocalTask['visibility'], - visibilityScopeOrgId: row.visibility_scope_org_id ?? null, - visibilityScopeOrgName: row.visibility_scope_org_name ?? null, - spaceId: row.space_id ?? null, - runtimeDir: row.runtime_dir ?? null, - createdAt: utc(row.created_at), - updatedAt: utc(row.updated_at), - feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null, - feedbackTags: row.feedback_tags ? JSON.parse(row.feedback_tags) : null, - feedbackComment: row.feedback_comment ?? null, - feedbackAt: row.feedback_at ? utc(row.feedback_at) : null, - shareToken: row.share_token ?? null, - sharedAt: row.shared_at ? utc(row.shared_at) : null, - browserSessionProfileId: row.browser_session_profile_id ?? null, - missionBrief: parseMissionBrief(row.mission_brief), - options: parseTaskOptions(row.options), - }; -} - -function parseMissionBrief(raw: string | null | undefined): MissionBrief | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - const brief = {} as MissionBrief; - for (const field of MISSION_BRIEF_FIELDS) { - brief[field] = typeof parsed[field] === 'string' ? parsed[field] : ''; - } - if (MISSION_BRIEF_FIELDS.every((field) => !brief[field])) return null; - return brief; - } catch { - return null; - } -} - -function parseTaskOptions(raw: string | null | undefined): Record { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record; - } - return {}; - } catch { - return {}; - } -} - -function rowToSpace(row: SpaceRow): Space { - return { - id: row.id, - kind: row.kind as Space['kind'], - title: row.title, - description: row.description, - ownerId: row.owner_id, - visibility: row.visibility as Space['visibility'], - visibilityScopeOrgId: row.visibility_scope_org_id, - status: row.status as Space['status'], - brandColor: row.brand_color, - workspaceDir: row.workspace_dir, - createdAt: utc(row.created_at), - updatedAt: utc(row.updated_at), - }; -} - -function rowToLocalTaskComment(row: LocalTaskCommentRow): LocalTaskComment { - return { - id: row.id, - taskId: row.task_id, - author: row.author, - kind: row.kind, - body: row.body, - attachments: parseCommentAttachments(row.attachments), - createdAt: utc(row.created_at), - injectedAt: row.injected_at ? utc(row.injected_at) : null, - }; -} - -/** Parse the JSON attachments column to a string[]; tolerate null/legacy/garbage. */ -function parseCommentAttachments(raw: string | null): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : []; - } catch { - return []; - } -} - -function rowToWorkerNode(row: WorkerNodeRow): WorkerNode { - return { - workerId: row.worker_id, - endpoint: row.endpoint, - enabled: row.enabled === 1, - healthy: row.healthy === 1, - roles: decodeTags(row.profile_tags), - availableModels: decodeAvailableModels(row.available_models), - inflightJobs: row.inflight_jobs, - maxConcurrency: row.max_concurrency, - lastError: row.last_error, - lastSeenAt: utc(row.last_seen_at), - updatedAt: utc(row.updated_at), - }; -} - -function rowToUser(row: UserRow): User { - return { - id: row.id, - email: row.email, - name: row.name, - avatarUrl: row.avatar_url, - role: row.role as 'admin' | 'user', - status: row.status as 'active' | 'pending' | 'disabled', - defaultVisibility: (row.default_visibility ?? 'private') as User['defaultVisibility'], - defaultVisibilityOrgId: row.default_visibility_org_id, - createdAt: utc(row.created_at), - updatedAt: utc(row.updated_at), - }; -} - -export function localTaskRepoName(taskId: number): string { - return `local/task-${taskId}`; -} export class Repository { private readonly db: Database.Database; @@ -1346,876 +60,122 @@ export class Repository { } private initSchema(): void { - this.runPreSchemaCompatibilityMigrations(); - const schemaPath = join(__dirname, 'schema.sql'); - const schema = readFileSync(schemaPath, 'utf-8'); - this.db.exec(schema); - this.ensureColumn('jobs', 'required_profile', "TEXT NOT NULL DEFAULT 'auto'"); - this.ensureColumn('jobs', 'task_class', "TEXT NOT NULL DEFAULT 'auto'"); - this.ensureColumn('worker_nodes', 'profile_tags', "TEXT NOT NULL DEFAULT ',auto,'"); - this.ensureColumn('worker_nodes', 'task_class_tags', "TEXT NOT NULL DEFAULT ',auto,'"); - this.ensureColumn('worker_nodes', 'available_models', 'TEXT'); - this.ensureColumn('worker_nodes', 'max_concurrency', 'INTEGER NOT NULL DEFAULT 1'); - this.ensureColumn('worker_nodes', 'last_error', 'TEXT'); - this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_profile_task_class ON jobs (status, required_profile, task_class)"); - this.db.exec("CREATE INDEX IF NOT EXISTS idx_worker_nodes_health ON worker_nodes (enabled, healthy, last_seen_at)"); - this.ensureColumn('jobs', 'parent_job_id', 'TEXT'); - this.ensureColumn('jobs', 'subtask_depth', 'INTEGER NOT NULL DEFAULT 0'); - this.ensureColumn('jobs', 'wait_reason', 'TEXT'); - this.ensureColumn('jobs', 'continued_from_job_id', 'TEXT'); - // Phase A (multi-team GPU pool): physical backend id assigned when - // the worker is a proxy. Sticky once set; never overwritten mid-job. - this.ensureColumn('jobs', 'last_backend_id', 'TEXT'); - this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_parent_job_id ON jobs (parent_job_id)"); - this.ensureColumn('local_tasks', 'feedback_rating', 'TEXT'); - this.ensureColumn('local_tasks', 'feedback_comment', 'TEXT'); - this.ensureColumn('local_tasks', 'feedback_tags', 'TEXT'); - this.ensureColumn('local_tasks', 'feedback_at', 'TEXT'); - this.migrateWaitingSubtasksStatus(); - // Auth migrations: owner_id columns - this.ensureColumn('jobs', 'owner_id', 'TEXT'); - this.ensureColumn('local_tasks', 'owner_id', 'TEXT'); - // #142: 実行中アクティビティ表示 - this.ensureColumn('jobs', 'current_activity', 'TEXT'); - // abortReason 細分化: agent-loop / piece-runner が出す構造化 abort code を保持 - this.ensureColumn('jobs', 'abort_reason', 'TEXT'); - // #134: 共有機能 - this.ensureColumn('local_tasks', 'share_token', 'TEXT'); - this.ensureColumn('local_tasks', 'shared_at', 'TEXT'); - this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_local_tasks_share_token ON local_tasks (share_token)"); - - // Ownership and visibility columns (3 tables) - for (const table of ['local_tasks', 'scheduled_tasks', 'jobs']) { - this.ensureColumn(table, 'owner_id', 'TEXT'); - this.ensureColumn(table, 'visibility', "TEXT NOT NULL DEFAULT 'private'"); - this.ensureColumn(table, 'visibility_scope_org_id', 'TEXT'); - } - - // User preferences - this.ensureColumn('users', 'default_visibility', "TEXT NOT NULL DEFAULT 'private'"); - this.ensureColumn('users', 'default_visibility_org_id', 'TEXT'); - - // Indexes and user_gitea_orgs table - this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_local_tasks_owner_id ON local_tasks(owner_id); - CREATE INDEX IF NOT EXISTS idx_local_tasks_visibility ON local_tasks(visibility, visibility_scope_org_id); - CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_owner_id ON scheduled_tasks(owner_id); - CREATE INDEX IF NOT EXISTS idx_jobs_owner_id ON jobs(owner_id); - CREATE INDEX IF NOT EXISTS idx_jobs_visibility ON jobs(visibility, visibility_scope_org_id); - - CREATE TABLE IF NOT EXISTS user_gitea_orgs ( - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - org_id TEXT NOT NULL, - org_name TEXT NOT NULL, - fetched_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (user_id, org_id) - ); - CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id); - `); - - // スペース基盤(計画1)。schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS spaces ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL DEFAULT 'case', - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - owner_id TEXT, - visibility TEXT NOT NULL DEFAULT 'private', - visibility_scope_org_id TEXT, - status TEXT NOT NULL DEFAULT 'open', - brand_color TEXT, - workspace_dir TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id); - CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal'; - `); - this.ensureColumn('local_tasks', 'space_id', 'TEXT'); - this.ensureColumn('local_tasks', 'workspace_mode', "TEXT NOT NULL DEFAULT 'persistent'"); - this.ensureColumn('jobs', 'space_id', 'TEXT'); - this.ensureColumn('scheduled_tasks', 'space_id', 'TEXT'); - - // タスク横断検索用の会話コメント索引(通常テーブルのみ)。schema.sql / migrate.ts と三重ミラー。 - // FTS5 仮想テーブル+同期トリガは src/db/migrate.ts の ensureTaskCommentFts が単一定義箇所として作成する - // (runMigrations() を経由しない bare `new Repository()` 構築でも FTS が確実に存在するように、ここからも呼ぶ)。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS task_comment_index ( - comment_id INTEGER PRIMARY KEY, - task_id INTEGER NOT NULL, - author TEXT, - kind TEXT, - created_at TEXT, - text TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_tci_task ON task_comment_index(task_id); - `); - ensureTaskCommentFts(this.db); - - // スペース・メンバー(協働者)。schema.sql / migrate.ts と三重ミラー。 - // owner は members 行を持たず owner_id で判定する。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS space_members ( - space_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('owner','editor','viewer')), - invited_by TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (space_id, user_id), - FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members (user_id); - `); - - // スペース・カレンダー(予定)。schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS calendar_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - space_id TEXT NOT NULL, - owner_id TEXT, - date TEXT NOT NULL, - end_date TEXT, - time TEXT, - end_time TEXT, - title TEXT NOT NULL, - description TEXT, - created_by TEXT NOT NULL DEFAULT 'user', - source_task_id INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date); - `); - - // スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS space_invites ( - token TEXT PRIMARY KEY, - space_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('editor','viewer')), - created_by TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT, - revoked_at TEXT, - FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites (space_id); - `); - // 公開アプリ共有リンク(read-only)。schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS app_share_links ( - token TEXT PRIMARY KEY, - space_id TEXT NOT NULL, - app_name TEXT NOT NULL, - created_by TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - revoked_at TEXT, - FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_app_share_links_space_app - ON app_share_links(space_id, app_name); - `); - // ツール要求の記録(RequestTool 能動申告 / 未許可ツール呼び出しの受動捕捉)。 - // schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS tool_requests ( - id TEXT PRIMARY KEY, - task_id TEXT, - job_id TEXT, - space_id TEXT, - piece_name TEXT NOT NULL, - movement_name TEXT NOT NULL, - tool_name TEXT NOT NULL, - reason TEXT, - category TEXT NOT NULL DEFAULT 'requested', - status TEXT NOT NULL DEFAULT 'pending', - grant_scope TEXT, - decided_by TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - decided_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_tool_requests_task ON tool_requests (task_id); - CREATE INDEX IF NOT EXISTS idx_tool_requests_piece ON tool_requests (piece_name); - CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status); - `); - // ワークスペースファイル来歴台帳。schema.sql / migrate.ts と三重ミラー。 - this.db.exec(` - CREATE TABLE IF NOT EXISTS workspace_file_provenance ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - space_id TEXT, - workspace_path TEXT NOT NULL, - rel_path TEXT NOT NULL, - created_by_task_id INTEGER, - created_by_job_id TEXT, - created_by_piece TEXT, - created_by_movement TEXT, - source_kind TEXT NOT NULL, - first_seen_at TEXT NOT NULL, - last_modified_by_task_id INTEGER, - last_modified_by_job_id TEXT, - last_modified_at TEXT, - checksum TEXT, - note TEXT, - UNIQUE(workspace_path, rel_path) - ); - CREATE INDEX IF NOT EXISTS idx_wsfile_prov_ws ON workspace_file_provenance (workspace_path); - CREATE INDEX IF NOT EXISTS idx_wsfile_prov_created ON workspace_file_provenance (workspace_path, created_by_task_id); - `); - // Per-space isolation for MCP servers / SSH connections (spaces foundation). - // NULL = global/legacy (resolved globally; Phase 1 is a no-op, no consumer - // reads this yet). Triple-path mirror: schema.sql + migrate.ts. - this.ensureColumn('mcp_servers', 'space_id', 'TEXT'); - this.ensureColumn('ssh_connections', 'space_id', 'TEXT'); - // workstream 2: ブラウザセッションプロファイルの per-space 化。owner_id は復号 - // DEK のため保持。Triple-path mirror: schema.sql + migrate.ts。 - this.ensureColumn('browser_session_profiles', 'space_id', 'TEXT'); - // 計画5: 実行ログ root。NULL = 後方互換で workspace_path/logs に解決。 - this.ensureColumn('local_tasks', 'runtime_dir', 'TEXT'); - this.ensureColumn('jobs', 'runtime_dir', 'TEXT'); - // Tool-request mechanism: per-task grant overlay (JSON string[]). - this.ensureColumn('local_tasks', 'granted_tools', 'TEXT'); - - // Browser session persistence (2026-05) — keep in sync with schema.sql - this.db.exec(` - CREATE TABLE IF NOT EXISTS user_deks ( - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - encrypted_dek BLOB NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE TABLE IF NOT EXISTS browser_session_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - space_id TEXT, - label TEXT NOT NULL, - start_url TEXT NOT NULL, - match_patterns TEXT NOT NULL DEFAULT '[]', - storage_origins TEXT NOT NULL DEFAULT '[]', - logged_in_selector TEXT, - login_url_patterns TEXT NOT NULL DEFAULT '[]', - encrypted_state_blob BLOB, - state_version INTEGER NOT NULL DEFAULT 0, - playwright_version TEXT, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked','error')), - last_saved_at TEXT, - last_used_at TEXT, - last_validated_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_bsp_owner ON browser_session_profiles(owner_id); - CREATE INDEX IF NOT EXISTS idx_bsp_space ON browser_session_profiles(space_id); - -- audit log: intentionally no FK — must survive deletion of referenced rows - CREATE TABLE IF NOT EXISTS browser_session_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL DEFAULT (datetime('now')), - actor_user_id TEXT, - profile_id INTEGER, - owner_id TEXT, - action TEXT NOT NULL CHECK (action IN ('create','save','decrypt','use','delete','expire','revoke','test','login_start','login_cancel')), - task_id INTEGER, - job_id TEXT, - result TEXT NOT NULL CHECK (result IN ('success','error')), - reason TEXT - ); - CREATE INDEX IF NOT EXISTS idx_bsa_profile ON browser_session_audit(profile_id); - CREATE INDEX IF NOT EXISTS idx_bsa_actor ON browser_session_audit(actor_user_id); - `); - - this.ensureColumn('local_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); - this.ensureColumn('scheduled_tasks', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); - this.ensureColumn('jobs', 'browser_session_profile_id', 'INTEGER REFERENCES browser_session_profiles(id) ON DELETE SET NULL'); - - // E: scheduled_tasks can now run a user script directly (without going - // through the agent / LLM loop). task_kind='agent' (default) keeps the - // pre-existing behavior; task_kind='script' uses script_name + script_params. - this.ensureColumn('scheduled_tasks', 'task_kind', "TEXT NOT NULL DEFAULT 'agent' CHECK (task_kind IN ('agent','script'))"); - this.ensureColumn('scheduled_tasks', 'script_name', 'TEXT'); - this.ensureColumn('scheduled_tasks', 'script_params', 'TEXT'); // JSON-encoded object or NULL - - // F: reflection jobs — task_kind distinguishes agent jobs from reflection - // jobs that run the self-improving-memory pipeline (no LLM task loop). - // payload carries JSON inputs (scope, trigger metadata, etc.). - this.ensureColumn('jobs', 'task_kind', "TEXT NOT NULL DEFAULT 'agent'"); - this.ensureColumn('jobs', 'payload', 'TEXT'); - - // G: reflection piece-edit cooldown tracking. - // reflection_piece_edits records each time the reflection pipeline writes - // a user's custom piece. The cooldown gate in piece-writer.ts queries - // countRecentPieceEdits to rate-limit piece rewrites. - this.db.exec(` - CREATE TABLE IF NOT EXISTS reflection_piece_edits ( - user_id TEXT NOT NULL, - piece_name TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, piece_name, created_at) - ); - CREATE INDEX IF NOT EXISTS idx_rpe_user_piece_time - ON reflection_piece_edits (user_id, piece_name, created_at DESC); - `); - - // The Side Info Panel dashboard widget feature was removed (2026-06); - // drop the legacy per-user widget table if an older DB still carries it. - this.db.exec(`DROP TABLE IF EXISTS user_dashboard_widgets;`); - - // H: reflection_metrics — one row per reflection job, records outcome, - // token usage, memory changes, and whether a piece edit was applied. - // Used by the /api/reflection/metrics endpoint (Phase 7.2) and future - // per-user budget enforcement (Phase 8.2). - this.db.exec(` - CREATE TABLE IF NOT EXISTS reflection_metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reflection_job_id TEXT NOT NULL, - original_job_id TEXT, - user_id TEXT NOT NULL, - piece_name TEXT, - outcome TEXT NOT NULL, - memory_changes INTEGER NOT NULL DEFAULT 0, - piece_edited INTEGER NOT NULL DEFAULT 0, - tokens_in INTEGER NOT NULL DEFAULT 0, - tokens_out INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_rm_user_time - ON reflection_metrics (user_id, created_at DESC); - `); - - // I: AAO Gateway Phase 2a — DB-backed virtual keys. - // Mirrors src/db/schema.sql and migrateGatewayVirtualKeys in migrate.ts; - // all three paths must stay in sync (project_db_migration_dual_path). - this.db.exec(` - CREATE TABLE IF NOT EXISTS gateway_virtual_keys ( - id TEXT PRIMARY KEY, - key_hash TEXT NOT NULL UNIQUE, - key_prefix TEXT NOT NULL, - team TEXT NOT NULL, - allowed_models TEXT, - source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')), - created_at TEXT NOT NULL, - created_by TEXT, - revoked_at TEXT, - revoked_by TEXT, - last_used_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active - ON gateway_virtual_keys (key_hash) - WHERE revoked_at IS NULL; - CREATE INDEX IF NOT EXISTS idx_gateway_keys_team - ON gateway_virtual_keys (team); - `); - - // I.b: AAO Gateway Phase 2b — budget / rate limit columns + - // gateway_key_usage table. Mirrors schema.sql + migrate.ts and uses - // the same PRAGMA-based idempotency pattern. - this.ensureColumn('gateway_virtual_keys', 'tokens_budget', 'INTEGER'); - this.ensureColumn('gateway_virtual_keys', 'rate_limit_rpm', 'INTEGER'); - this.db.exec(` - CREATE TABLE IF NOT EXISTS gateway_key_usage ( - key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE, - period_start TEXT NOT NULL, - tokens_in INTEGER NOT NULL DEFAULT 0, - tokens_out INTEGER NOT NULL DEFAULT 0, - requests INTEGER NOT NULL DEFAULT 0, - last_updated_at TEXT NOT NULL, - PRIMARY KEY (key_id, period_start) - ); - CREATE INDEX IF NOT EXISTS idx_gateway_usage_key - ON gateway_key_usage (key_id); - `); - - // Per-user daily LLM usage (gateway + direct). Mirrors schema.sql + - // migrate.ts (dual-path rule). Separate lens from gateway_key_usage. - // Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md - this.db.exec(` - CREATE TABLE IF NOT EXISTS llm_usage_daily ( - day TEXT NOT NULL, - user_id TEXT NOT NULL, - source TEXT NOT NULL, - model TEXT NOT NULL, - route TEXT NOT NULL, - tokens_in INTEGER NOT NULL DEFAULT 0, - tokens_out INTEGER NOT NULL DEFAULT 0, - requests INTEGER NOT NULL DEFAULT 0, - last_updated_at TEXT NOT NULL, - PRIMARY KEY (day, user_id, source, model, route) - ); - CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day - ON llm_usage_daily (user_id, day); - `); - - // Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the - // write target; daily kept as frozen archive). Mirrors schema.sql + - // migrate.ts (dual-path). Backfill of the daily archive lives in migrate.ts - // so it runs once on upgrade, not on every fresh initSchema. - // Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md - this.db.exec(` - CREATE TABLE IF NOT EXISTS llm_usage_hourly ( - hour TEXT NOT NULL, - user_id TEXT NOT NULL, - source TEXT NOT NULL, - model TEXT NOT NULL, - route TEXT NOT NULL, - tokens_in INTEGER NOT NULL DEFAULT 0, - tokens_out INTEGER NOT NULL DEFAULT 0, - requests INTEGER NOT NULL DEFAULT 0, - last_updated_at TEXT NOT NULL, - PRIMARY KEY (hour, user_id, source, model, route) - ); - CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour - ON llm_usage_hourly (user_id, hour); - `); + return schemaRepo.initSchema(this.db); } private runPreSchemaCompatibilityMigrations(): void { - const hasBrowserSessionProfiles = this.db - .prepare("SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?") - .get("table", "browser_session_profiles"); - if (!hasBrowserSessionProfiles) { - return; - } - - const columns = this.db - .prepare("PRAGMA table_info(browser_session_profiles)") - .all() as Array<{ name: string }>; - if (columns.some((column) => column.name === "space_id")) { - return; - } - - this.db.exec("ALTER TABLE browser_session_profiles ADD COLUMN space_id TEXT"); + return schemaRepo.runPreSchemaCompatibilityMigrations(this.db); } private ensureColumn(tableName: string, columnName: string, definition: string): void { - const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>; - if (columns.some((column) => column.name === columnName)) { - return; - } - this.db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`).run(); + return schemaRepo.ensureColumn(this.db, tableName, columnName, definition); } private migrateWaitingSubtasksStatus(): void { - // Check if jobs table already has waiting_subtasks in its CHECK constraint - const tableInfo = this.db.prepare( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='jobs'" - ).get() as { sql: string } | undefined; - if (!tableInfo || tableInfo.sql.includes('waiting_subtasks')) return; - - // Recreate jobs table with updated CHECK constraint - logger.info('Repository: migrating jobs table to support waiting_subtasks status...'); - this.db.transaction(() => { - this.db.exec(` - CREATE TABLE IF NOT EXISTS jobs_v2 ( - id TEXT PRIMARY KEY, - repo TEXT NOT NULL, - issue_number INTEGER NOT NULL, - pr_number INTEGER, - status TEXT NOT NULL DEFAULT 'queued' - CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')), - piece_name TEXT NOT NULL DEFAULT 'general', - required_profile TEXT NOT NULL DEFAULT 'auto', - task_class TEXT NOT NULL DEFAULT 'auto', - current_movement TEXT, - instruction TEXT NOT NULL DEFAULT '', - branch_name TEXT, - worktree_path TEXT, - attempt INTEGER NOT NULL DEFAULT 1, - max_attempts INTEGER NOT NULL DEFAULT 3, - next_retry_at TEXT, - error_summary TEXT, - resume_movement TEXT, - ask_count INTEGER NOT NULL DEFAULT 0, - worker_id TEXT, - parent_job_id TEXT, - subtask_depth INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - INSERT INTO jobs_v2 - SELECT id, repo, issue_number, pr_number, status, piece_name, required_profile, task_class, - current_movement, instruction, branch_name, worktree_path, attempt, max_attempts, - next_retry_at, error_summary, resume_movement, ask_count, worker_id, - NULL AS parent_job_id, 0 AS subtask_depth, created_at, updated_at - FROM jobs; - DROP TABLE jobs; - ALTER TABLE jobs_v2 RENAME TO jobs; - `); - })(); - logger.info('Repository: jobs table migration complete'); + return schemaRepo.migrateWaitingSubtasksStatus(this.db); } - // Non-terminal job states. A task with a job in any of these already has work - // pending/running, so a new user comment should be appended to it rather than - // spawning a second job. waiting_human is excluded on purpose: a comment there - // is the answer that resumes via a fresh job. - private static readonly PENDING_JOB_STATES = ['queued', 'dispatching', 'running', 'waiting_subtasks', 'retry'] as const; - private insertJobSync(params: CreateJobParams): Job { - const id = randomUUID(); - const now = new Date().toISOString(); - const pieceName = params.pieceName ?? 'chat'; - const maxAttempts = params.maxAttempts ?? 3; - const resumeMovement = params.resumeMovement ?? null; - const askCount = params.askCount ?? 0; - const requiredRole = deriveJobRole(params.instruction, params.role ?? params.profile); + return jobsRepo.insertJobSync(this.db, params); + } - this.db - .prepare( - `INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at) - VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)` - ) - .run({ - id, - repo: params.repo, - issueNumber: params.issueNumber, - pieceName, - instruction: params.instruction, - maxAttempts, - resumeMovement, - askCount, - requiredRole, - parentJobId: params.parentJobId ?? null, - continuedFromJobId: params.continuedFromJobId ?? null, - subtaskDepth: params.subtaskDepth ?? 0, - ownerId: params.ownerId ?? null, - visibility: params.visibility ?? 'private', - visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, - browserSessionProfileId: params.browserSessionProfileId ?? null, - taskKind: params.taskKind ?? 'agent', - payload: params.payload ?? null, - spaceId: params.spaceId ?? null, - runtimeDir: params.runtimeDir ?? null, - now, - }); + private getJobSync(id: string): Job | null { + return jobsRepo.getJobSync(this.db, id); + } - const job = this.getJobSync(id); - if (!job) throw new Error(`createJob: failed to retrieve created job ${id}`); - return job; + private async buildSubtaskInfos(subJobs: Job[], maxDepth: number = 3): Promise { + return jobsRepo.buildSubtaskInfos(this.db, subJobs, maxDepth); } async createJob(params: CreateJobParams): Promise { - return this.insertJobSync(params); + return jobsRepo.createJob(this.db, params); } - /** - * Atomically reuse-or-create. If a non-terminal job already exists for the - * issue, return it (created=false) without inserting; otherwise insert a new - * queued job (created=true). better-sqlite3 transactions run synchronously, so - * the check+insert can't interleave with a concurrent request — this closes - * the TOCTOU race where two near-simultaneous comments each spawned a job, - * leaving the newest (queued) duplicate as the task's latestJob while an older - * job actually ran (status stuck on "Inbox"). - */ - createJobIfNoPending( - params: CreateJobParams, - opts?: { blockOnToolRequestPause?: boolean }, - ): { job: Job; created: boolean; blockedByToolRequest?: boolean } { - const states = Repository.PENDING_JOB_STATES; - const placeholders = states.map(() => '?').join(','); - const tx = this.db.transaction((): { job: Job; created: boolean; blockedByToolRequest?: boolean } => { - // Tool-request mechanism: atomically refuse to create a normal resume job - // while a job is parked for tool approval (resolved via approve/deny, which - // re-queues the original). Done inside the same transaction as the insert - // so there is no check-then-act race with the engine parking the job. - if (opts?.blockOnToolRequestPause) { - const parked = this.db - .prepare( - `SELECT * FROM jobs WHERE repo = ? AND issue_number = ? - AND status = 'waiting_human' AND wait_reason = 'tool_request' - ORDER BY created_at DESC, rowid DESC LIMIT 1`, - ) - .get(params.repo, params.issueNumber) as JobRow | undefined; - if (parked) return { job: rowToJob(parked), created: false, blockedByToolRequest: true }; - } - const existing = this.db - .prepare( - `SELECT * FROM jobs WHERE repo = ? AND issue_number = ? AND status IN (${placeholders}) - ORDER BY created_at DESC, rowid DESC LIMIT 1`, - ) - .get(params.repo, params.issueNumber, ...states) as JobRow | undefined; - if (existing) return { job: rowToJob(existing), created: false }; - return { job: this.insertJobSync(params), created: true }; - }); - return tx(); + createJobIfNoPending(params: CreateJobParams, opts?: { blockOnToolRequestPause?: boolean }): { job: Job; created: boolean; blockedByToolRequest?: boolean } { + return jobsRepo.createJobIfNoPending(this.db, params, opts); } async getJob(id: string, opts?: { viewer?: Express.User }): Promise { - const viewerClause = opts?.viewer - ? buildVisibilityWhere(opts.viewer, 'j', { spaceColumn: 'space_id' }) - : { clause: '1=1', params: [] as unknown[] }; - const row = this.db - .prepare(`SELECT j.* FROM jobs j WHERE j.id = ? AND ${viewerClause.clause}`) - .get(id, ...viewerClause.params) as JobRow | undefined; - return row ? rowToJob(row) : null; + return jobsRepo.getJob(this.db, id, opts); } async createSpace(params: CreateSpaceParams): Promise { - const id = params.id ?? randomUUID(); - this.db - .prepare( - `INSERT INTO spaces (id, kind, title, description, owner_id, visibility, visibility_scope_org_id, status, brand_color, workspace_dir) - VALUES (@id, @kind, @title, @description, @ownerId, @visibility, @visibilityScopeOrgId, 'open', @brandColor, @workspaceDir)` - ) - .run({ - id, - kind: params.kind, - title: params.title, - description: params.description ?? '', - ownerId: params.ownerId, - visibility: params.visibility ?? 'private', - visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, - brandColor: params.brandColor ?? null, - workspaceDir: params.workspaceDir ?? null, - }); - const space = await this.getSpace(id); - if (!space) throw new Error('createSpace: failed to load inserted space'); - return space; + return spacesRepo.createSpace(this.db, params); } async getSpace(id: string, opts?: { viewer?: Express.User }): Promise { - // 個人スペースは所有者のみ可視(admin 含む)。buildSpaceVisibilityWhere が - // kind='personal' を owner_id 一致に限定し、kind='case' は従来通り扱う。 - const viewerClause = opts?.viewer - ? buildSpaceVisibilityWhere(opts.viewer, 's') - : { clause: '1=1', params: [] as unknown[] }; - const row = this.db - .prepare(`SELECT s.* FROM spaces s WHERE s.id = ? AND ${viewerClause.clause}`) - .get(id, ...viewerClause.params) as SpaceRow | undefined; - return row ? rowToSpace(row) : null; + return spacesRepo.getSpace(this.db, id, opts); } async listSpaces(opts?: { viewer?: Express.User; includeArchived?: boolean }): Promise { - // 個人スペースは所有者のみ可視(admin 含む)。これにより admin の一覧/レールに - // 他ユーザーの個人スペースが混ざらず、UI の誤ワークスペース解決を防ぐ。 - const viewerClause = opts?.viewer - ? buildSpaceVisibilityWhere(opts.viewer, 's') - : { clause: '1=1', params: [] as unknown[] }; - const statusClause = opts?.includeArchived ? '1=1' : "s.status = 'open'"; - const rows = this.db - .prepare(` - SELECT s.* FROM spaces s - WHERE ${statusClause} AND ${viewerClause.clause} - ORDER BY s.kind = 'personal' DESC, s.updated_at DESC - `) - .all(...viewerClause.params) as SpaceRow[]; - return rows.map(rowToSpace); + return spacesRepo.listSpaces(this.db, opts); } - async updateSpace( - id: string, - patch: { title?: string; description?: string; brandColor?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null }, - ): Promise { - const sets: string[] = []; - const vals: unknown[] = []; - if (patch.title !== undefined) { sets.push('title = ?'); vals.push(patch.title); } - if (patch.description !== undefined) { sets.push('description = ?'); vals.push(patch.description); } - if (patch.brandColor !== undefined) { sets.push('brand_color = ?'); vals.push(patch.brandColor); } - if (patch.visibility !== undefined) { sets.push('visibility = ?'); vals.push(patch.visibility); } - if (patch.visibilityScopeOrgId !== undefined) { sets.push('visibility_scope_org_id = ?'); vals.push(patch.visibilityScopeOrgId); } - if (sets.length === 0) return this.getSpace(id); - sets.push("updated_at = datetime('now')"); - this.db.prepare(`UPDATE spaces SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); - return this.getSpace(id); + async updateSpace(id: string, patch: { title?: string; description?: string; brandColor?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null }): Promise { + return spacesRepo.updateSpace(this.db, id, patch); } async archiveSpace(id: string): Promise { - this.db - .prepare(`UPDATE spaces SET status = 'archived', updated_at = datetime('now') WHERE id = ? AND kind = 'case'`) - .run(id); + return spacesRepo.archiveSpace(this.db, id); } async setSpaceWorkspaceDir(id: string, workspaceDir: string): Promise { - this.db - .prepare(`UPDATE spaces SET workspace_dir = ?, updated_at = datetime('now') WHERE id = ?`) - .run(workspaceDir, id); + return spacesRepo.setSpaceWorkspaceDir(this.db, id, workspaceDir); } - /** - * ロールバック専用のハード削除。通常は archiveSpace を使う。 - * 個人スペースは「削除不可」(spec §5.7) なので kind='case' のみ消す。 - * この backstop により、誤って personal id を渡しても個人スペースは保護される。 - */ async hardDeleteSpace(id: string): Promise { - const tx = this.db.transaction((sid: string) => { - // Crypto-shred space-owned credential DEKs EXPLICITLY (Space-as-Principal P2). - // We don't rely on FK ON DELETE CASCADE because migrate.ts intentionally - // omits FK clauses, so a migrated DB's space_ssh_deks would otherwise - // outlive the space. Gate on kind='case' (subquery) so a mistaken personal - // id never shreds a personal space's DEK. Runs before the spaces delete so - // the guard subquery still sees the row. - const isCase = `AND space_id IN (SELECT id FROM spaces WHERE kind = 'case')`; - this.db.prepare(`DELETE FROM space_ssh_deks WHERE space_id = ? ${isCase}`).run(sid); - this.db.prepare(`DELETE FROM space_browser_deks WHERE space_id = ? ${isCase}`).run(sid); - this.db.prepare(`DELETE FROM spaces WHERE id = ? AND kind = 'case'`).run(sid); - }); - tx(id); + return spacesRepo.hardDeleteSpace(this.db, id); } - /** - * ユーザーの個人スペースを返す。無ければ生成する(冪等)。 - * UNIQUE INDEX idx_spaces_personal_owner により1ユーザー1個に保たれる。 - * 既存ユーザーの初回アクセス移行を兼ねる(spec §7)。 - * 個人スペースの workspace_dir はこの計画では NULL(フォルダ物理ワイヤリングは計画2)。 - */ async ensurePersonalSpace(ownerId: string, displayName?: string): Promise { - const existing = this.db - .prepare(`SELECT s.* FROM spaces s WHERE s.owner_id = ? AND s.kind = 'personal'`) - .get(ownerId) as SpaceRow | undefined; - if (existing) return rowToSpace(existing); - return this.createSpace({ - kind: 'personal', - title: displayName ? `${displayName} の個人ワークスペース` : '個人ワークスペース', - ownerId, - visibility: 'private', - }); + return spacesRepo.ensurePersonalSpace(this.db, ownerId, displayName); } - // ─── スペース・メンバー(協働者)CRUD ───────────────────────────── - // owner_id は members 行を持たない根オーナー。これらは追加協働者だけを扱う。 - - /** - * メンバーを追加または上書きする(冪等)。既存行があれば role/invited_by を更新。 - * owner_id 本人を渡してはならない(既に owner)。呼び出し側でガードする。 - */ async addSpaceMember(params: { spaceId: string; userId: string; role: SpaceMemberRoleValue; invitedBy?: string | null; }): Promise { - this.db - .prepare( - `INSERT INTO space_members (space_id, user_id, role, invited_by) - VALUES (@spaceId, @userId, @role, @invitedBy) - ON CONFLICT(space_id, user_id) DO UPDATE SET - role = excluded.role, - invited_by = excluded.invited_by` - ) - .run({ - spaceId: params.spaceId, - userId: params.userId, - role: params.role, - invitedBy: params.invitedBy ?? null, - }); + return spacesRepo.addSpaceMember(this.db, params); } - /** スペースのメンバー一覧(users JOIN で表示情報付き)。owner_id は含まない。 */ async listSpaceMembers(spaceId: string): Promise { - const rows = this.db - .prepare(` - SELECT sm.space_id, sm.user_id, sm.role, sm.invited_by, sm.created_at, - u.name AS name, u.email AS email, u.avatar_url AS avatar_url - FROM space_members sm - LEFT JOIN users u ON u.id = sm.user_id - WHERE sm.space_id = ? - ORDER BY sm.created_at ASC, sm.user_id ASC - `) - .all(spaceId) as Array<{ - space_id: string; - user_id: string; - role: string; - invited_by: string | null; - created_at: string; - name: string | null; - email: string | null; - avatar_url: string | null; - }>; - return rows.map((r) => ({ - spaceId: r.space_id, - userId: r.user_id, - role: r.role as SpaceMemberRoleValue, - invitedBy: r.invited_by, - createdAt: r.created_at, - name: r.name, - email: r.email, - avatarUrl: r.avatar_url, - })); + return spacesRepo.listSpaceMembers(this.db, spaceId); } - /** - * 指定ユーザーのメンバーロールを返す。メンバー行が無ければ null。 - * 根オーナー(owner_id)は members 行を持たないため null を返す。 - * 呼び出し側は owner_id を別途 'owner' として扱うこと(canManageSpace 参照)。 - */ getSpaceMemberRole(spaceId: string, userId: string): SpaceMemberRoleValue | null { - const row = this.db - .prepare(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`) - .get(spaceId, userId) as { role: string } | undefined; - return row ? (row.role as SpaceMemberRoleValue) : null; + return spacesRepo.getSpaceMemberRole(this.db, spaceId, userId); } - /** スペースのツール制限ポリシー(JSON 文字列)を返す。未設定なら null。 */ getSpaceToolPolicy(spaceId: string): string | null { - const row = this.db - .prepare(`SELECT tool_policy FROM spaces WHERE id = ?`) - .get(spaceId) as { tool_policy: string | null } | undefined; - return row?.tool_policy ?? null; + return spacesRepo.getSpaceToolPolicy(this.db, spaceId); } - /** スペースのツール制限ポリシーを更新する。null を渡すとクリア。 */ setSpaceToolPolicy(spaceId: string, policyJson: string | null): void { - this.db - .prepare(`UPDATE spaces SET tool_policy = ?, updated_at = datetime('now') WHERE id = ?`) - .run(policyJson, spaceId); + return spacesRepo.setSpaceToolPolicy(this.db, spaceId, policyJson); } - /** スペースの Python パッケージ desired-state(JSON 文字列)を返す。未設定なら null。 */ getSpacePythonPackages(spaceId: string): string | null { - const row = this.db - .prepare(`SELECT python_packages FROM spaces WHERE id = ?`) - .get(spaceId) as { python_packages: string | null } | undefined; - return row?.python_packages ?? null; + return spacesRepo.getSpacePythonPackages(this.db, spaceId); } - /** スペースの Python パッケージ desired-state を更新する。null を渡すとクリア。 */ setSpacePythonPackages(spaceId: string, packagesJson: string | null): void { - this.db - .prepare(`UPDATE spaces SET python_packages = ?, updated_at = datetime('now') WHERE id = ?`) - .run(packagesJson, spaceId); + return spacesRepo.setSpacePythonPackages(this.db, spaceId, packagesJson); } - /** - * 指定 user がスペースの行(タスク/ファイル/ログ等)を VIEW できるか。 - * buildVisibilityWhere の membership OR ブランチと同じルールをコード上で再現する: - * admin → true / space.owner_id === user.id(根オーナー)→ true / - * space_members に (spaceId, user.id) 行あり → true / それ以外 → false。 - * 二次ゲート(canViewTask / canUserSeeTask)が呼び出す。書き込み判定ではない。 - */ userCanViewSpace(spaceId: string, user: { id: string; role: string }): boolean { - if (user.role === 'admin') return true; - const owns = this.db - .prepare(`SELECT 1 FROM spaces WHERE id = ? AND owner_id = ?`) - .get(spaceId, user.id); - if (owns) return true; - const member = this.db - .prepare(`SELECT 1 FROM space_members WHERE space_id = ? AND user_id = ?`) - .get(spaceId, user.id); - return !!member; + return spacesRepo.userCanViewSpace(this.db, spaceId, user); } - /** 既存メンバーのロールを変更する。行が無ければ no-op。 */ - async updateSpaceMemberRole( - spaceId: string, - userId: string, - role: SpaceMemberRoleValue, - ): Promise { - this.db - .prepare(`UPDATE space_members SET role = ? WHERE space_id = ? AND user_id = ?`) - .run(role, spaceId, userId); + async updateSpaceMemberRole(spaceId: string, userId: string, role: SpaceMemberRoleValue): Promise { + return spacesRepo.updateSpaceMemberRole(this.db, spaceId, userId, role); } - /** メンバーを除去する。行が無ければ no-op。 */ async removeSpaceMember(spaceId: string, userId: string): Promise { - this.db - .prepare(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`) - .run(spaceId, userId); + return spacesRepo.removeSpaceMember(this.db, spaceId, userId); } - // ─── スペース招待リンク(再利用トークン)───────────────────────────── - private rowToSpaceInvite(row: { token: string; space_id: string; @@ -2225,412 +185,98 @@ export class Repository { expires_at: string | null; revoked_at: string | null; }): SpaceInvite { - return { - token: row.token, - spaceId: row.space_id, - role: row.role as SpaceInviteRole, - createdBy: row.created_by, - createdAt: row.created_at, - expiresAt: row.expires_at, - revokedAt: row.revoked_at, - }; + return spacesRepo.rowToSpaceInvite(row); } - /** - * 招待が今この瞬間に有効か(無効化されておらず、期限切れでない)。 - * 期限比較は SQLite と同じ datetime('now')(UTC)基準の ISO 文字列辞書順。 - */ isSpaceInviteValid(invite: SpaceInvite | null | undefined): invite is SpaceInvite { - if (!invite) return false; - if (invite.revokedAt) return false; - if (invite.expiresAt) { - const now = this.db.prepare(`SELECT datetime('now') AS n`).get() as { n: string }; - if (invite.expiresAt <= now.n) return false; - } - return true; + return spacesRepo.isSpaceInviteValid(this.db, invite); } - /** - * スペースの招待リンクを (再)生成する。再利用方針=スペースごとに有効リンク1本。 - * 既存 invite 行は削除してから新トークンを 1 行 insert する。 - * @param expiresInDays 有効日数。未指定/null は無期限。期限は SQLite の - * datetime('now','+N days') で保存し、isSpaceInviteValid と同じ形式に揃える。 - */ createSpaceInvite(params: { spaceId: string; role: SpaceInviteRole; createdBy?: string | null; expiresInDays?: number | null; }): SpaceInvite { - const token = randomUUID(); - // 整数日数のみ受理(負/0/非整数は無期限扱い)。文字列補間するため厳格に検証する。 - const days = - typeof params.expiresInDays === 'number' && - Number.isInteger(params.expiresInDays) && - params.expiresInDays > 0 - ? params.expiresInDays - : null; - const tx = this.db.transaction(() => { - this.db.prepare(`DELETE FROM space_invites WHERE space_id = ?`).run(params.spaceId); - this.db - .prepare( - `INSERT INTO space_invites (token, space_id, role, created_by, expires_at) - VALUES (@token, @spaceId, @role, @createdBy, - ${days === null ? 'NULL' : `datetime('now', '+${days} days')`})`, - ) - .run({ - token, - spaceId: params.spaceId, - role: params.role, - createdBy: params.createdBy ?? null, - }); - }); - tx(); - return this.getSpaceInviteByToken(token)!; + return spacesRepo.createSpaceInvite(this.db, params); } - /** スペースの現行 invite(有効・無効問わず最新の1本)。無ければ null。 */ getSpaceInvite(spaceId: string): SpaceInvite | null { - const row = this.db - .prepare(`SELECT * FROM space_invites WHERE space_id = ? ORDER BY created_at DESC LIMIT 1`) - .get(spaceId) as Parameters[0] | undefined; - return row ? this.rowToSpaceInvite(row) : null; + return spacesRepo.getSpaceInvite(this.db, spaceId); } - /** トークンで invite を引く(有効性は呼び出し側で判定)。無ければ null。 */ getSpaceInviteByToken(token: string): SpaceInvite | null { - const row = this.db - .prepare(`SELECT * FROM space_invites WHERE token = ?`) - .get(token) as Parameters[0] | undefined; - return row ? this.rowToSpaceInvite(row) : null; + return spacesRepo.getSpaceInviteByToken(this.db, token); } - /** スペースの現行 invite を無効化する。行が無ければ no-op。 */ revokeSpaceInvite(spaceId: string): void { - this.db - .prepare( - `UPDATE space_invites SET revoked_at = datetime('now') - WHERE space_id = ? AND revoked_at IS NULL`, - ) - .run(spaceId); + return spacesRepo.revokeSpaceInvite(this.db, spaceId); } - // ─── 公開アプリ共有リンク(read-only) ─────────────────────────────── - // - // スペース内の 1 ワークスペースアプリ(apps/{appName}/)を、ログイン不要の - // 公開 URL で read-only 共有するためのトークン。失効後の再発行は新トークンを - // 作る(失効済みトークンは再利用しない)ため、未失効リンクが既にある場合のみ - // 再利用する。 - - /** (spaceId, appName) の未失効リンクがあれば再利用、無ければ新規発行して token を返す。 */ createAppShareLink(spaceId: string, appName: string, createdBy: string | null): { token: string } { - const existing = this.db - .prepare( - `SELECT token FROM app_share_links - WHERE space_id = ? AND app_name = ? AND revoked_at IS NULL - ORDER BY created_at DESC LIMIT 1`, - ) - .get(spaceId, appName) as { token: string } | undefined; - if (existing) return { token: existing.token }; - const token = randomUUID(); - this.db - .prepare( - `INSERT INTO app_share_links (token, space_id, app_name, created_by) - VALUES (?, ?, ?, ?)`, - ) - .run(token, spaceId, appName, createdBy ?? null); - return { token }; + return appSharesRepo.createAppShareLink(this.db, spaceId, appName, createdBy); } - /** (spaceId, appName) の最新リンク(有効・無効問わず)を返す。無ければ null。 */ getAppShareLink(spaceId: string, appName: string): { token: string; revokedAt: string | null } | null { - const row = this.db - .prepare( - `SELECT token, revoked_at FROM app_share_links - WHERE space_id = ? AND app_name = ? - ORDER BY created_at DESC LIMIT 1`, - ) - .get(spaceId, appName) as { token: string; revoked_at: string | null } | undefined; - if (!row) return null; - return { token: row.token, revokedAt: row.revoked_at ?? null }; + return appSharesRepo.getAppShareLink(this.db, spaceId, appName); } - /** (spaceId, appName) の未失効リンクを全て失効させる。行が無ければ no-op。 */ revokeAppShareLink(spaceId: string, appName: string): void { - this.db - .prepare( - `UPDATE app_share_links SET revoked_at = datetime('now') - WHERE space_id = ? AND app_name = ? AND revoked_at IS NULL`, - ) - .run(spaceId, appName); + return appSharesRepo.revokeAppShareLink(this.db, spaceId, appName); } - /** トークンを (spaceId, appName) に解決する。失効済み・不正トークンは null。 */ resolveAppShareToken(token: string): { spaceId: string; appName: string } | null { - const row = this.db - .prepare( - `SELECT space_id, app_name FROM app_share_links - WHERE token = ? AND revoked_at IS NULL`, - ) - .get(token) as { space_id: string; app_name: string } | undefined; - if (!row) return null; - return { spaceId: row.space_id, appName: row.app_name }; + return appSharesRepo.resolveAppShareToken(this.db, token); } - /** - * タスクの実効スペースを解決し FolderContext を返す。 - * - space_id があればそのスペース、無ければ owner の個人スペース(無ければ lazy 生成)。 - * - owner も無い(no-auth)の場合は 'local' の個人スペース。 - * 返り値は userPiecesDir/readUserAgentsMd 等にそのまま渡せる { rootDir, leafId }。 - */ - async resolveTaskFolderContext( - taskId: number, - userFolderRoot: string, - ): Promise { - const { resolveSpaceFolder } = await import('../spaces/folder-resolver.js'); - const row = this.db - .prepare(`SELECT space_id, owner_id FROM local_tasks WHERE id = ?`) - .get(taskId) as { space_id: string | null; owner_id: string | null } | undefined; - const ownerId = row?.owner_id ?? 'local'; - if (row?.space_id) { - const space = await this.getSpace(row.space_id); - if (space) return resolveSpaceFolder(space, userFolderRoot); - // 参照先スペースが消えている場合は個人スペースにフォールバック - } - const personal = await this.ensurePersonalSpace(ownerId); - return resolveSpaceFolder(personal, userFolderRoot); + async resolveTaskFolderContext(taskId: number, userFolderRoot: string): Promise { + return localTasksRepo.resolveTaskFolderContext(this.db, taskId, userFolderRoot); } async createLocalTask(params: CreateLocalTaskParams): Promise { - const result = this.db - .prepare( - `INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options) - VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)` - ) - .run({ - title: params.title, - titleSource: params.titleSource ?? 'auto', - body: params.body, - pieceName: params.pieceName ?? 'chat', - profile: params.profile ?? 'auto', - outputFormat: params.outputFormat ?? 'markdown', - askPolicy: params.askPolicy ?? 'low', - priority: params.priority ?? 'medium', - workspacePath: params.workspacePath ?? null, - workspaceMode: params.workspaceMode ?? 'persistent', - ownerId: params.ownerId ?? null, - visibility: params.visibility ?? 'private', - visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, - browserSessionProfileId: params.browserSessionProfileId ?? null, - spaceId: params.spaceId ?? null, - options: JSON.stringify(params.options ?? {}), - }); - - const task = await this.getLocalTask(Number(result.lastInsertRowid)); - if (!task) throw new Error('createLocalTask: failed to load inserted task'); - return task; - } - - /** サブジョブ一覧を SubtaskInfo[] に変換。waiting_subtasks の子は再帰的に children を取得する */ - private async buildSubtaskInfos(subJobs: Job[], maxDepth: number = 3): Promise { - return Promise.all(subJobs.map(async (j): Promise => { - const info: SubtaskInfo = { - id: j.id, - issueNumber: j.issueNumber, - status: j.status, - instruction: j.instruction, - worktreePath: j.worktreePath, - createdAt: j.createdAt, - updatedAt: j.updatedAt, - }; - // 再帰: waiting_subtasks のサブタスクは孫タスク情報も取得 - if (j.status === 'waiting_subtasks' && maxDepth > 0) { - const grandChildren = await this.getSubJobs(j.id); - if (grandChildren.length > 0) { - info.children = await this.buildSubtaskInfos(grandChildren, maxDepth - 1); - info.childCount = grandChildren.length; - info.childCompleted = grandChildren.filter(g => - ['succeeded', 'failed', 'cancelled'].includes(g.status) - ).length; - } - } - return info; - })); + return localTasksRepo.createLocalTask(this.db, params); } async getLocalTask(taskId: number, opts?: { viewer?: Express.User }): Promise { - const viewerClause = opts?.viewer - ? buildVisibilityWhere(opts.viewer, 'lt', { spaceColumn: 'space_id' }) - : { clause: '1=1', params: [] as unknown[] }; - const row = this.db - .prepare(` - SELECT lt.*, - ${LOCAL_TASK_DISPLAY_SELECT} - FROM local_tasks lt - ${LOCAL_TASK_DISPLAY_JOIN} - WHERE lt.id = ? AND ${viewerClause.clause} - `) - .get(taskId, ...viewerClause.params) as LocalTaskRow | undefined; - if (!row) return null; - const task = rowToLocalTask(row); - task.latestJob = await this.getLatestJobForIssue(localTaskRepoName(taskId), taskId); - // サブタスク情報を付与 - if (task.latestJob) { - const subJobs = await this.getSubJobs(task.latestJob.id); - if (subJobs.length > 0) { - task.subtasks = await this.buildSubtaskInfos(subJobs); - task.subtaskCount = subJobs.length; - task.subtaskCompleted = subJobs.filter(j => - ['succeeded', 'failed', 'cancelled'].includes(j.status) - ).length; - } - } - return task; + return localTasksRepo.getLocalTask(this.db, taskId, opts); } async shareLocalTask(taskId: number): Promise { - const existing = await this.getLocalTask(taskId); - if (!existing) throw new Error(`Task ${taskId} not found`); - if (existing.shareToken) return existing.shareToken; - - const token = randomUUID(); - this.db.prepare( - `UPDATE local_tasks SET share_token = ?, shared_at = datetime('now'), updated_at = datetime('now') WHERE id = ?` - ).run(token, taskId); - return token; + return localTasksRepo.shareLocalTask(this.db, taskId); } async unshareLocalTask(taskId: number): Promise { - this.db.prepare( - `UPDATE local_tasks SET share_token = NULL, shared_at = NULL, updated_at = datetime('now') WHERE id = ?` - ).run(taskId); + return localTasksRepo.unshareLocalTask(this.db, taskId); } - /** - * Mission Brief: partial-replace update. Only fields explicitly provided - * are written; undefined leaves the field untouched. Passing all-empty - * strings is treated as "clear the brief" (NULL in storage). - * - * Returns the merged brief so callers can echo it back to the client - * without an extra read. - */ - async updateMissionBrief( - taskId: number, - patch: Partial, - ): Promise { - return this.updateMissionBriefSync(taskId, patch); + async updateMissionBrief(taskId: number, patch: Partial): Promise { + return localTasksRepo.updateMissionBrief(this.db, taskId, patch); } - /** Sync variant used by the engine's MissionBriefIO so it can be called - * from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync - * underneath anyway. */ updateMissionBriefSync(taskId: number, patch: Partial): MissionBrief | null { - const row = this.db - .prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`) - .get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined; - const existing = parseMissionBrief(row?.mission_brief ?? null); - const next = {} as MissionBrief; - for (const field of MISSION_BRIEF_FIELDS) { - next[field] = patch[field] !== undefined ? patch[field] : existing?.[field] ?? ''; - } - const allEmpty = MISSION_BRIEF_FIELDS.every((field) => !next[field]); - const stored = allEmpty ? null : JSON.stringify(next); - - // Derive the task title from the agent's goal (no LLM call). Only when the - // goal value actually changed (agents re-send an unchanged brief across - // iterations — re-deriving every time would churn updated_at and flicker - // the title) and the user hasn't taken manual control (a user edit pins - // title_source='user' and is never overwritten). - const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? ''); - const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user') - ? buildTitleFromGoal(next.goal) - : ''; - - // Atomic: persist the brief and the derived title as one unit so a crash - // between them can't leave the title out of sync with the goal. - this.db.transaction(() => { - this.db.prepare( - `UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?` - ).run(stored, taskId); - if (derivedTitle) { - this.db.prepare( - `UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?` - ).run(derivedTitle, taskId); - } - })(); - return allEmpty ? null : next; + return localTasksRepo.updateMissionBriefSync(this.db, taskId, patch); } - /** Sync read of the mission brief column. Used by MissionBriefIO.read() */ getMissionBriefSync(taskId: number): MissionBrief | null { - const row = this.db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined; - return parseMissionBrief(row?.mission_brief ?? null); + return localTasksRepo.getMissionBriefSync(this.db, taskId); } - /** - * Construct a MissionBriefIO bound to a specific local task. The engine - * uses this to thread mission brief read/write into the ToolContext - * without leaking the repository instance into tool code. - */ makeMissionBriefIO(taskId: number): import('../engine/tools/core.js').MissionBriefIO { - return { - read: () => this.getMissionBriefSync(taskId), - update: (patch) => this.updateMissionBriefSync(taskId, patch), - }; + return localTasksRepo.makeMissionBriefIO(this.db, taskId); } - /** - * Construct a TaskConversationIO bound to a single local task (comments) + - * this run's transcript path. Used by SearchTaskConversation / - * ReadTaskConversation; the binding is what scopes those tools to the current - * task so they can't read another task's logs. - */ - makeTaskConversationIO( - taskId: number, - transcriptPath?: string, - ): import('../engine/tools/core.js').TaskConversationIO { - return { - listComments: () => this.listLocalTaskComments(taskId), - transcriptPath, - }; + makeTaskConversationIO(taskId: number, transcriptPath?: string): import('../engine/tools/core.js').TaskConversationIO { + return localTasksRepo.makeTaskConversationIO(this.db, taskId, transcriptPath); } - /** - * Construct a WorkspaceTaskSearchIO scoped to the current task + owner. - * Used by SearchWorkspaceTasks / ReadWorkspaceTaskAround; searchWorkspaceTasks / - * readWorkspaceTaskAround themselves enforce the space/owner scoping, this is - * just the binding closure. fts5Available lets the tool degrade gracefully - * when the FTS5 index table is missing (e.g. pre-migration DB). - */ - makeWorkspaceTaskSearchIO( - currentTaskId: number, - ownerId: string, - ): import('../engine/tools/core.js').WorkspaceTaskSearchIO { - const fts5Available = !!this.db - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_comment_fts'") - .get(); - return { - fts5Available, - search: (query, opts) => this.searchWorkspaceTasks(currentTaskId, ownerId, query, opts), - around: (commentId, before, after) => - this.readWorkspaceTaskAround(currentTaskId, ownerId, commentId, before, after), - }; + makeTranscriptOnlyConversationIO(transcriptPath: string | undefined): import('../engine/tools/core.js').TaskConversationIO { + return localTasksRepo.makeTranscriptOnlyConversationIO(transcriptPath); } - // ── Workspace file provenance ──────────────────────────────────────────── - // Every read/write is scoped by workspace_path (the shared files-tree root), - // which is the isolation boundary: a query for workspace A can never surface - // workspace B's rows (and thus never another user's task lineage in a shared - // space). schema.sql / migrate.ts / initSchema keep the table in triple sync. + makeWorkspaceTaskSearchIO(currentTaskId: number, ownerId: string): import('../engine/tools/core.js').WorkspaceTaskSearchIO { + return taskSearchRepo.makeWorkspaceTaskSearchIO(this.db, currentTaskId, ownerId); + } - /** - * Upsert a provenance row for one workspace file. - * - event 'create' with no existing row → inserts the creator identity. - * - existing row (any event) → updates only the last-modified fields, so the - * original creator + source_kind are preserved (Edit / overwrite semantics). - * - event 'modify' with no existing row → backfills a row for a file that - * pre-dates the ledger (source_kind=imported_existing, creator NULL). - */ recordProvenance(params: { workspacePath: string; relPath: string; @@ -2644,384 +290,31 @@ export class Repository { checksum?: string | null; note?: string | null; }): void { - const now = new Date().toISOString(); - const taskId = params.taskId ?? null; - const jobId = params.jobId ?? null; - const checksum = params.checksum ?? null; - const existing = this.db - .prepare('SELECT id FROM workspace_file_provenance WHERE workspace_path = ? AND rel_path = ?') - .get(params.workspacePath, params.relPath) as { id: number } | undefined; - - if (existing) { - // Preserve creator + source_kind; only advance the last-modified pointer. - this.db - .prepare( - `UPDATE workspace_file_provenance - SET last_modified_by_task_id = ?, last_modified_by_job_id = ?, - last_modified_at = ?, checksum = COALESCE(?, checksum) - WHERE id = ?`, - ) - .run(taskId, jobId, now, checksum, existing.id); - return; - } - - // No row yet. A create event knows the creator; a modify event means the - // file pre-dates the ledger → backfill with an unknown creator. - const sourceKind = - params.event === 'create' ? params.sourceKind ?? 'unknown' : 'imported_existing'; - const createdTask = params.event === 'create' ? taskId : null; - const createdJob = params.event === 'create' ? jobId : null; - const createdPiece = params.event === 'create' ? params.piece ?? null : null; - const createdMovement = params.event === 'create' ? params.movement ?? null : null; - this.db - .prepare( - `INSERT INTO workspace_file_provenance - (space_id, workspace_path, rel_path, created_by_task_id, created_by_job_id, - created_by_piece, created_by_movement, source_kind, first_seen_at, - last_modified_by_task_id, last_modified_by_job_id, last_modified_at, checksum, note) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - params.spaceId ?? null, - params.workspacePath, - params.relPath, - createdTask, - createdJob, - createdPiece, - createdMovement, - sourceKind, - now, - taskId, - jobId, - now, - checksum, - params.note ?? null, - ); + return provenanceRepo.recordProvenance(this.db, params); } - getProvenance( - workspacePath: string, - relPath: string, - ): import('../engine/tools/core.js').FileProvenanceRecord | null { - const row = this.db - .prepare('SELECT * FROM workspace_file_provenance WHERE workspace_path = ? AND rel_path = ?') - .get(workspacePath, relPath) as WorkspaceFileProvenanceRow | undefined; - return row ? rowToFileProvenance(row) : null; + getProvenance(workspacePath: string, relPath: string): import('../engine/tools/core.js').FileProvenanceRecord | null { + return provenanceRepo.getProvenance(this.db, workspacePath, relPath); } - listProvenance( - workspacePath: string, - filters?: import('../engine/tools/core.js').FileProvenanceListFilters, - ): import('../engine/tools/core.js').FileProvenanceRecord[] { - const clauses = ['workspace_path = ?']; - const args: unknown[] = [workspacePath]; - if (filters?.pathPrefix) { - clauses.push('rel_path LIKE ? ESCAPE \'\\\''); - args.push(escapeLike(filters.pathPrefix) + '%'); - } - if (filters?.sourceKind) { - clauses.push('source_kind = ?'); - args.push(filters.sourceKind); - } - if (typeof filters?.createdByTaskId === 'number') { - clauses.push('created_by_task_id = ?'); - args.push(filters.createdByTaskId); - } - if (typeof filters?.lastModifiedByTaskId === 'number') { - clauses.push('last_modified_by_task_id = ?'); - args.push(filters.lastModifiedByTaskId); - } - if (filters?.includeUnknown === false) { - clauses.push("source_kind NOT IN ('unknown', 'imported_existing')"); - } - // Cap hard at 500 rows regardless of caller input (bounded output). - const limit = Math.min(Math.max(filters?.limit ?? 200, 1), 500); - const rows = this.db - .prepare( - `SELECT * FROM workspace_file_provenance - WHERE ${clauses.join(' AND ')} - ORDER BY rel_path ASC - LIMIT ?`, - ) - .all(...args, limit) as WorkspaceFileProvenanceRow[]; - return rows.map(rowToFileProvenance); + listProvenance(workspacePath: string, filters?: import('../engine/tools/core.js').FileProvenanceListFilters): import('../engine/tools/core.js').FileProvenanceRecord[] { + return provenanceRepo.listProvenance(this.db, workspacePath, filters); } - /** Construct a workspace-bound read IO for GetFileProvenance / ListWorkspaceFiles. */ makeFileProvenanceIO(workspacePath: string): import('../engine/tools/core.js').FileProvenanceIO { - return { - get: (relPath) => this.getProvenance(workspacePath, relPath), - list: (filters) => this.listProvenance(workspacePath, filters), - }; + return provenanceRepo.makeFileProvenanceIO(this.db, workspacePath); } async getLocalTaskByShareToken(token: string): Promise { - const row = this.db - .prepare(` - SELECT lt.*, - ${LOCAL_TASK_DISPLAY_SELECT} - FROM local_tasks lt - ${LOCAL_TASK_DISPLAY_JOIN} - WHERE lt.share_token = ? - `) - .get(token) as LocalTaskRow | undefined; - if (!row) return null; - const task = rowToLocalTask(row); - task.latestJob = await this.getLatestJobForIssue(localTaskRepoName(task.id), task.id); - if (task.latestJob) { - const subJobs = await this.getSubJobs(task.latestJob.id); - if (subJobs.length > 0) { - task.subtasks = await this.buildSubtaskInfos(subJobs); - task.subtaskCount = subJobs.length; - task.subtaskCompleted = subJobs.filter(j => - ['succeeded', 'failed', 'cancelled'].includes(j.status) - ).length; - } - } - return task; + return localTasksRepo.getLocalTaskByShareToken(this.db, token); } async listLocalTasks(filter?: { ownerId?: string; viewer?: Express.User }): Promise { - // 1. Single JOIN query: local_tasks LEFT JOIN jobs (latest per task via correlated subquery) - const conditions: string[] = []; - const queryParams: unknown[] = []; - if (filter?.ownerId) { - conditions.push('lt.owner_id = ?'); - queryParams.push(filter.ownerId); - } - if (filter?.viewer) { - const w = buildVisibilityWhere(filter.viewer, 'lt', { spaceColumn: 'space_id' }); - conditions.push(w.clause); - queryParams.push(...w.params); - } - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - - const joinedRows = this.db - .prepare(` - SELECT - lt.*, - ${LOCAL_TASK_DISPLAY_SELECT}, - j.id AS job_id, - j.repo AS job_repo, - j.issue_number AS job_issue_number, - j.pr_number AS job_pr_number, - j.status AS job_status, - j.piece_name AS job_piece_name, - j.required_profile AS job_required_profile, - j.task_class AS job_task_class, - j.current_movement AS job_current_movement, - j.current_activity AS job_current_activity, - j.instruction AS job_instruction, - j.branch_name AS job_branch_name, - j.worktree_path AS job_worktree_path, - j.attempt AS job_attempt, - j.max_attempts AS job_max_attempts, - j.next_retry_at AS job_next_retry_at, - j.error_summary AS job_error_summary, - j.abort_reason AS job_abort_reason, - j.resume_movement AS job_resume_movement, - j.wait_reason AS job_wait_reason, - j.ask_count AS job_ask_count, - j.worker_id AS job_worker_id, - j.last_backend_id AS job_last_backend_id, - j.parent_job_id AS job_parent_job_id, - j.continued_from_job_id AS job_continued_from_job_id, - j.subtask_depth AS job_subtask_depth, - j.owner_id AS job_owner_id, - j.visibility AS job_visibility, - j.visibility_scope_org_id AS job_visibility_scope_org_id, - j.created_at AS job_created_at, - j.updated_at AS job_updated_at, - j.context_prompt_tokens AS job_context_prompt_tokens, - j.context_limit_tokens AS job_context_limit_tokens, - j.context_updated_at AS job_context_updated_at, - j.browser_session_profile_id AS job_browser_session_profile_id, - j.task_kind AS job_task_kind, - j.payload AS job_payload - FROM local_tasks lt - ${LOCAL_TASK_DISPLAY_JOIN} - LEFT JOIN jobs j ON j.id = ( - SELECT j2.id FROM jobs j2 - WHERE j2.repo = 'local/task-' || lt.id - AND j2.issue_number = lt.id - ORDER BY j2.created_at DESC, j2.rowid DESC - LIMIT 1 - ) - ${whereClause} - ORDER BY lt.updated_at DESC, lt.id DESC - `) - .all(...queryParams) as Array; - - // Build tasks with latestJob from joined data - const tasks: LocalTask[] = []; - const jobIds: string[] = []; - - for (const row of joinedRows) { - const task = rowToLocalTask(row); - if (row.job_id) { - task.latestJob = rowToJob({ - id: row.job_id, - repo: row.job_repo!, - issue_number: row.job_issue_number!, - pr_number: row.job_pr_number ?? null, - status: row.job_status!, - piece_name: row.job_piece_name!, - current_movement: row.job_current_movement ?? null, - current_activity: row.job_current_activity ?? null, - instruction: row.job_instruction!, - branch_name: row.job_branch_name ?? null, - worktree_path: row.job_worktree_path ?? null, - attempt: row.job_attempt!, - max_attempts: row.job_max_attempts!, - next_retry_at: row.job_next_retry_at ?? null, - error_summary: row.job_error_summary ?? null, - abort_reason: row.job_abort_reason ?? null, - resume_movement: row.job_resume_movement ?? null, - wait_reason: row.job_wait_reason ?? null, - ask_count: row.job_ask_count!, - worker_id: row.job_worker_id ?? null, - last_backend_id: row.job_last_backend_id ?? null, - parent_job_id: row.job_parent_job_id ?? null, - continued_from_job_id: row.job_continued_from_job_id ?? null, - subtask_depth: row.job_subtask_depth ?? 0, - required_profile: row.job_required_profile!, - task_class: row.job_task_class!, - owner_id: row.job_owner_id ?? null, - visibility: row.job_visibility ?? null, - visibility_scope_org_id: row.job_visibility_scope_org_id ?? null, - context_prompt_tokens: row.job_context_prompt_tokens ?? null, - context_limit_tokens: row.job_context_limit_tokens ?? null, - context_updated_at: row.job_context_updated_at ?? null, - browser_session_profile_id: row.job_browser_session_profile_id ?? null, - task_kind: row.job_task_kind ?? 'agent', - payload: row.job_payload ?? null, - // この join は latestJob 表示用。スペース解決は local_tasks.space_id を使うため - // ここでは job の space_id / runtime_dir を選択しておらず null で足りる。 - space_id: null, - runtime_dir: null, - created_at: row.job_created_at!, - updated_at: row.job_updated_at!, - }); - jobIds.push(row.job_id); - } else { - task.latestJob = null; - } - tasks.push(task); - } - - // 2. Single query for all sub-jobs - if (jobIds.length > 0) { - const placeholders = jobIds.map(() => '?').join(', '); - const subJobRows = this.db - .prepare(` - SELECT * FROM ( - SELECT j.*, ROW_NUMBER() OVER ( - PARTITION BY j.parent_job_id, j.issue_number - ORDER BY j.created_at DESC, j.rowid DESC - ) AS rn - FROM jobs j - WHERE j.parent_job_id IN (${placeholders}) - ) WHERE rn = 1 - ORDER BY parent_job_id, issue_number ASC - `) - .all(...jobIds) as JobRow[]; - - // Group sub-jobs by parent_job_id - const subJobsByParent = new Map(); - for (const row of subJobRows) { - const parentId = row.parent_job_id!; - if (!subJobsByParent.has(parentId)) { - subJobsByParent.set(parentId, []); - } - subJobsByParent.get(parentId)!.push(rowToJob(row)); - } - - // 3. Attach subtask info to tasks - for (const task of tasks) { - if (task.latestJob && subJobsByParent.has(task.latestJob.id)) { - const subJobs = subJobsByParent.get(task.latestJob.id)!; - task.subtasks = await this.buildSubtaskInfos(subJobs); - task.subtaskCount = subJobs.length; - task.subtaskCompleted = subJobs.filter(j => - ['succeeded', 'failed', 'cancelled'].includes(j.status) - ).length; - } - } - } - - return tasks; + return localTasksRepo.listLocalTasks(this.db, filter); } async updateLocalTask(taskId: number, updates: Partial>): Promise { - const setClauses: string[] = ["updated_at = datetime('now')"]; - const params: Record = { taskId }; - const fieldMap: Record = { - title: 'title', - titleSource: 'title_source', - body: 'body', - pieceName: 'piece_name', - profile: 'profile', - outputFormat: 'output_format', - askPolicy: 'ask_policy', - priority: 'priority', - state: 'state', - workspacePath: 'workspace_path', - runtimeDir: 'runtime_dir', - visibility: 'visibility', - visibilityScopeOrgId: 'visibility_scope_org_id', - }; - - for (const [jsKey, dbCol] of Object.entries(fieldMap)) { - const val = (updates as Record)[jsKey]; - if (val !== undefined) { - setClauses.push(`${dbCol} = @${jsKey}`); - params[jsKey] = val; - } - } - - if (setClauses.length === 1) return; - - this.db - .prepare(`UPDATE local_tasks SET ${setClauses.join(', ')} WHERE id = @taskId`) - .run(params); + return localTasksRepo.updateLocalTask(this.db, taskId, updates); } async updateFeedback(taskId: number, feedback: { @@ -3029,612 +322,193 @@ export class Repository { tags: string[]; comment: string | null; }): Promise { - this.db - .prepare(` - UPDATE local_tasks - SET feedback_rating = @rating, - feedback_tags = @tags, - feedback_comment = @comment, - feedback_at = datetime('now'), - updated_at = datetime('now') - WHERE id = @taskId - `) - .run({ - taskId, - rating: feedback.rating, - tags: JSON.stringify(feedback.tags), - comment: feedback.comment, - }); + return localTasksRepo.updateFeedback(this.db, taskId, feedback); } async addLocalTaskComment(taskId: number, author: string, body: string, kind: string = 'comment', attachments: string[] = []): Promise { - const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null; - const result = this.db - .prepare('INSERT INTO local_task_comments (task_id, author, kind, body, attachments) VALUES (?, ?, ?, ?, ?)') - .run(taskId, author, kind, body, attachmentsJson); - this.db - .prepare("UPDATE local_tasks SET updated_at = datetime('now') WHERE id = ?") - .run(taskId); - const row = this.db - .prepare('SELECT * FROM local_task_comments WHERE id = ?') - .get(Number(result.lastInsertRowid)) as LocalTaskCommentRow | undefined; - if (!row) throw new Error('addLocalTaskComment: failed to load inserted comment'); - this.indexTaskComment(row.id, taskId, author, kind, row.created_at, body, attachmentsJson); - return rowToLocalTaskComment(row); + return localTasksRepo.addLocalTaskComment(this.db, taskId, author, body, kind, attachments); } - /** best-effort に1コメントを索引する。失敗してもコメント書き込みを壊さない。 */ - indexTaskComment( - commentId: number, taskId: number, author: string, kind: string, - createdAt: string, body: string, attachments: string | null, - ): void { - try { - const text = normalizeCommentToIndexText(kind, body, attachments); - if (text === null) return; - this.db.prepare( - `INSERT OR IGNORE INTO task_comment_index (comment_id, task_id, author, kind, created_at, text) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run(commentId, taskId, author, kind, createdAt, text); - } catch (err) { - logger.warn(`[task-index] indexTaskComment failed comment=${commentId}: ${(err as Error).message}`); - } + indexTaskComment(commentId: number, taskId: number, author: string, kind: string, createdAt: string, body: string, attachments: string | null): void { + return taskSearchRepo.indexTaskComment(this.db, commentId, taskId, author, kind, createdAt, body, attachments); } - /** - * 正規化済みの各語を個別に引用して FTS5 MATCH 文字列を組む(Google 的な暗黙 AND)。 - * 各語をダブルクォートで囲むので、`AND`/`OR`/`NEAR`/`*`/`"` などが語に混じっても - * FTS5 演算子として解釈されず、インジェクションを防ぐ。語同士はこちらが制御する - * ` AND ` でつなぐため「全語を含む」検索になる。呼び出し側で引用符除去済みを前提とする。 - */ private buildFtsMatch(terms: string[]): string { - return terms.map((t) => `"${t}"`).join(' AND '); + return taskSearchRepo.buildFtsMatch(terms); } - async searchWorkspaceTasks( - currentTaskId: number, ownerId: string, query: string, - opts: { limit?: number; kind?: string; author?: string; taskId?: number } = {}, - ): Promise> { - const q = (query ?? '').trim(); - if (!q) return []; - const cur = this.db.prepare('SELECT space_id, owner_id FROM local_tasks WHERE id = ?') - .get(currentTaskId) as { space_id: string | null; owner_id: string | null } | undefined; - if (!cur) return []; - - // スペース帰属確認(owner または space_members メンバー)。非帰属なら空。 - if (cur.space_id) { - const member = this.db.prepare( - `SELECT 1 FROM spaces WHERE id = @s AND owner_id = @u - UNION SELECT 1 FROM space_members WHERE space_id = @s AND user_id = @u`, - ).get({ s: cur.space_id, u: ownerId }); - if (!member) return []; - } - - const limit = Math.min(Math.max(opts.limit ?? 10, 1), 30); - // Google 的な暗黙 AND: 空白区切りの各語を「すべて含む」ものにヒットさせる。 - // 各語から埋め込み二重引用符を除去(空白化)してトリムし、空になった語は捨てる。 - // これで純粋な記号だけの「語」が AND を汚染して常に0件になるのを防ぐ。 - const terms = q.split(/\s+/).map((t) => t.replace(/"/g, ' ').trim()).filter(Boolean); - if (terms.length === 0) return []; // 引用符・記号のみで実質的な検索語が無い - // trigram は3文字未満の語を MATCH できないため、短い語が1つでもあれば LIKE 経路 - // (語ごとの AND-of-LIKE。LIKE は語長に依存せず動く)にフォールバックする。 - const useLike = terms.some((t) => t.length < 3); - const scope = cur.space_id - ? 'lt.space_id = @spaceId' - : 'lt.space_id IS NULL AND lt.owner_id = @ownerId'; - const filters = [ - opts.kind ? 'i.kind = @kind' : '', - opts.author ? 'i.author = @author' : '', - opts.taskId ? 'i.task_id = @taskFilter' : '', - ].filter(Boolean).join(' AND '); - const filterSql = filters ? ` AND ${filters}` : ''; - - const params: Record = { - cur: currentTaskId, spaceId: cur.space_id, ownerId, - kind: opts.kind, author: opts.author, taskFilter: opts.taskId, limit, - }; - - let sql: string; - if (useLike) { - // 各語を LIKE で AND 結合(位置・順序を問わず全語を含む)。値はパラメータ束縛、 - // 列名・プレースホルダ名は固定文字列なのでインジェクションしない。 - const likeConds = terms.map((_, i) => `i.text LIKE @like${i}`).join(' AND '); - terms.forEach((t, i) => { params[`like${i}`] = `%${t}%`; }); - sql = ` - SELECT i.task_id AS taskId, lt.title AS taskTitle, i.comment_id AS commentId, - i.author, i.kind, i.created_at AS createdAt, i.text - FROM task_comment_index i - JOIN local_tasks lt ON lt.id = i.task_id - WHERE (${likeConds}) AND i.task_id != @cur AND (${scope})${filterSql} - ORDER BY i.created_at DESC LIMIT @limit`; - } else { - params.match = this.buildFtsMatch(terms); - sql = ` - SELECT i.task_id AS taskId, lt.title AS taskTitle, i.comment_id AS commentId, - i.author, i.kind, i.created_at AS createdAt, i.text - FROM task_comment_fts f - JOIN task_comment_index i ON i.comment_id = f.rowid - JOIN local_tasks lt ON lt.id = i.task_id - WHERE f.text MATCH @match AND i.task_id != @cur AND (${scope})${filterSql} - ORDER BY bm25(task_comment_fts) LIMIT @limit`; - } - return this.db.prepare(sql).all(params) as any; + async searchWorkspaceTasks(currentTaskId: number, ownerId: string, query: string, opts: { limit?: number; kind?: string; author?: string; taskId?: number } = {}): Promise> { + return taskSearchRepo.searchWorkspaceTasks(this.db, currentTaskId, ownerId, query, opts); } - async readWorkspaceTaskAround( - currentTaskId: number, ownerId: string, commentId: number, before: number, after: number, - ) { - const cur = this.db.prepare('SELECT space_id, owner_id FROM local_tasks WHERE id = ?') - .get(currentTaskId) as { space_id: string | null; owner_id: string | null } | undefined; - if (!cur) return null; - const target = this.db.prepare('SELECT task_id FROM local_task_comments WHERE id = ?') - .get(commentId) as { task_id: number } | undefined; - if (!target) return null; - const t = this.db.prepare('SELECT id, title, space_id, owner_id FROM local_tasks WHERE id = ?') - .get(target.task_id) as { id: number; title: string; space_id: string | null; owner_id: string | null } | undefined; - if (!t) return null; - // スコープ確認: 同一スペース(or 同 owner の個人スペース) - const inScope = cur.space_id ? t.space_id === cur.space_id : (t.space_id === null && t.owner_id === ownerId); - if (!inScope) return null; - - const all = await this.listLocalTaskComments(target.task_id); - const pos = all.findIndex((x) => x.id === commentId); - if (pos < 0) return null; - const slice = all.slice(Math.max(0, pos - before), pos + after + 1); - return { - taskId: t.id, taskTitle: t.title, - comments: slice.map((x) => ({ commentId: x.id, author: x.author, kind: x.kind, createdAt: x.createdAt, body: x.body, isCenter: x.id === commentId })), - }; + async readWorkspaceTaskAround(currentTaskId: number, ownerId: string, commentId: number, before: number, after: number) { + return taskSearchRepo.readWorkspaceTaskAround(this.db, currentTaskId, ownerId, commentId, before, after); } async listLocalTaskComments(taskId: number): Promise { - const rows = this.db - .prepare('SELECT * FROM local_task_comments WHERE task_id = ? ORDER BY created_at ASC, id ASC') - .all(taskId) as LocalTaskCommentRow[]; - return rows.map(rowToLocalTaskComment); + return localTasksRepo.listLocalTaskComments(this.db, taskId); } async getUninjectedComments(taskId: number, sinceId: number = 0): Promise { - const rows = this.db - .prepare( - `SELECT * FROM local_task_comments - WHERE task_id = ? AND id > ? AND author = 'user' AND injected_at IS NULL - ORDER BY id ASC` - ) - .all(taskId, sinceId) as LocalTaskCommentRow[]; - return rows.map(rowToLocalTaskComment); + return localTasksRepo.getUninjectedComments(this.db, taskId, sinceId); } markCommentsInjected(commentIds: number[]): void { - if (commentIds.length === 0) return; - const placeholders = commentIds.map(() => '?').join(','); - this.db - .prepare(`UPDATE local_task_comments SET injected_at = datetime('now') WHERE id IN (${placeholders})`) - .run(...commentIds); + return localTasksRepo.markCommentsInjected(this.db, commentIds); } - /** - * Latest agent-authored "result" or "ask" comment for a task. Used by the - * piece handoff feature to surface the previous job's terminal output as - * context to a continuation job's LLM. Returns null when none exist - * (e.g., task has not yet completed any job). - */ async getLatestResultComment(taskId: number): Promise<{ body: string; kind: string; createdAt: string } | null> { - const row = this.db - .prepare( - `SELECT body, kind, created_at - FROM local_task_comments - WHERE task_id = ? AND author = 'agent' AND kind IN ('result', 'ask') - ORDER BY created_at DESC - LIMIT 1` - ) - .get(taskId) as { body: string; kind: string; created_at: string } | undefined; - return row ? { body: row.body, kind: row.kind, createdAt: row.created_at } : null; + return localTasksRepo.getLatestResultComment(this.db, taskId); } - private getJobSync(id: string): Job | null { - const row = this.db - .prepare('SELECT * FROM jobs WHERE id = ?') - .get(id) as JobRow | undefined; - return row ? rowToJob(row) : null; - } - - /** - * ジョブの現在のステータスを同期的に取得する。 - * キャンセルチェックなど、非同期が使えない箇所で利用する。 - */ getJobStatusSync(id: string): JobStatus | null { - const row = this.db - .prepare('SELECT status FROM jobs WHERE id = ?') - .get(id) as { status: string } | undefined; - return row ? (row.status as JobStatus) : null; + return jobsRepo.getJobStatusSync(this.db, id); } - /** - * Returns true if `workerId` currently has at least one job with status='running'. - * Used by the Side Info Panel's worker status endpoint. Boolean-only on purpose: - * never expose the job id/title/owner to other users in the shared panel. - */ isWorkerBusy(workerId: string): boolean { - const row = this.db - .prepare(`SELECT 1 AS hit FROM jobs WHERE worker_id = ? AND status = 'running' LIMIT 1`) - .get(workerId) as { hit: number } | undefined; - return !!row; + return jobsRepo.isWorkerBusy(this.db, workerId); } - /** - * いま実行中 (status='running') のジョブを worker_id ごとにまとめ、占有ユーザーの - * 表示名と種別 (`task_kind`: 'agent' 通常 / 'reflection' 学習 等) を返す。 - * **admin 専用の監視用**(ワーカー / GPU パネルで「誰が・何で GPU を占有しているか」を - * 出す)。一般ユーザーには呼び出し側で渡さない(dashboard-workers の privacy 既定を維持)。 - * - * `user` は `users.name`。null(owner 不明 / no-auth 'local')は owner_id →'unknown' - * の順でフォールバック。`(user, kind)` の組で重複排除し、1 ワーカーに複数ユーザー / - * 複数種別が同居するケースに対応する。 - */ listRunningJobOwnersByWorker(): Map> { - const rows = this.db - .prepare( - `SELECT j.worker_id AS workerId, j.owner_id AS ownerId, j.task_kind AS taskKind, u.name AS ownerName - FROM jobs j - LEFT JOIN users u ON u.id = j.owner_id - WHERE j.status = 'running' AND j.worker_id IS NOT NULL`, - ) - .all() as Array<{ workerId: string; ownerId: string | null; taskKind: string | null; ownerName: string | null }>; - const byWorker = new Map>(); - for (const r of rows) { - const user = (r.ownerName && r.ownerName.trim()) || r.ownerId || 'unknown'; - const kind = r.taskKind || 'agent'; - const list = byWorker.get(r.workerId); - if (list) { - if (!list.some((o) => o.user === user && o.kind === kind)) list.push({ user, kind }); - } else { - byWorker.set(r.workerId, [{ user, kind }]); - } - } - return byWorker; + return jobsRepo.listRunningJobOwnersByWorker(this.db); } async updateJob(id: string, updates: Partial>): Promise { - const setClauses: string[] = ["updated_at = datetime('now')"]; - const params: Record = { id }; - - const fieldMap: Record = { - status: 'status', - pieceName: 'piece_name', - currentMovement: 'current_movement', - currentActivity: 'current_activity', - instruction: 'instruction', - branchName: 'branch_name', - worktreePath: 'worktree_path', - prNumber: 'pr_number', - attempt: 'attempt', - maxAttempts: 'max_attempts', - nextRetryAt: 'next_retry_at', - errorSummary: 'error_summary', - abortReason: 'abort_reason', - resumeMovement: 'resume_movement', - waitReason: 'wait_reason', - askCount: 'ask_count', - workerId: 'worker_id', - lastBackendId: 'last_backend_id', - parentJobId: 'parent_job_id', - subtaskDepth: 'subtask_depth', - requiredRole: 'required_profile', - requiredProfile: 'required_profile', - }; - - for (const [jsKey, dbCol] of Object.entries(fieldMap)) { - const val = (updates as Record)[jsKey]; - if (val !== undefined) { - setClauses.push(`${dbCol} = @${jsKey}`); - params[jsKey] = val; - } - } - - if (setClauses.length === 1) return; // updated_at のみ = 実質変更なし - - this.db - .prepare(`UPDATE jobs SET ${setClauses.join(', ')} WHERE id = @id`) - .run(params); + return jobsRepo.updateJob(this.db, id, updates); } - /** ジョブの updated_at のみを更新(ハートビート用)。updateJob は変更フィールドなしだと早期リターンするため別メソッド */ touchJobUpdatedAt(id: string): void { - this.db.prepare("UPDATE jobs SET updated_at = datetime('now') WHERE id = ?").run(id); + return jobsRepo.touchJobUpdatedAt(this.db, id); } - /** - * Re-queue jobs parked with wait_reason='mcp_auth_required' for the given owner. - * Worker re-evaluates required_mcp on next pickup and will pause again if other servers - * are still unauthorized. _serverId is accepted for API symmetry but not used at SQL time - * (filtering by piece's required_mcp happens at the worker side). - * - * Returns the number of jobs actually re-queued. - */ resumeMcpWaitingJobs(ownerId: string, _serverId: string): number { - const result = this.db - .prepare( - `UPDATE jobs - SET status='queued', wait_reason=NULL, updated_at=datetime('now') - WHERE status='waiting_human' AND wait_reason='mcp_auth_required' - AND owner_id = ?`, - ) - .run(ownerId); - return result.changes; + return jobsRepo.resumeMcpWaitingJobs(this.db, ownerId, _serverId); } - /** - * Re-queue a job parked with wait_reason='tool_request' after a user - * decided (approve/deny) the pending tool request. The worker resumes from - * `resume_movement` and re-reads the task's granted_tools. Returns the number - * of jobs re-queued (0 if the job is gone or no longer parked for approval). - */ resumeToolRequestJob(jobId: string): number { - const result = this.db - .prepare( - `UPDATE jobs - SET status='queued', wait_reason=NULL, updated_at=datetime('now') - WHERE id = ? AND status='waiting_human' AND wait_reason='tool_request'`, - ) - .run(jobId); - return result.changes; + return jobsRepo.resumeToolRequestJob(this.db, jobId); + } + + resumePackageRequestJob(jobId: string): number { + return jobsRepo.resumePackageRequestJob(this.db, jobId); } async lockIssue(repo: string, issueNumber: number, jobId: string): Promise { - try { - this.db - .prepare('INSERT INTO issue_locks (repo, issue_number, job_id) VALUES (?, ?, ?)') - .run(repo, issueNumber, jobId); - return true; - } catch { - return false; - } + return jobsRepo.lockIssue(this.db, repo, issueNumber, jobId); } async unlockIssue(repo: string, issueNumber: number): Promise { - this.db - .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') - .run(repo, issueNumber); + return jobsRepo.unlockIssue(this.db, repo, issueNumber); } async deleteJobsForIssue(repo: string, issueNumber: number): Promise { - const result = this.db - .prepare('DELETE FROM jobs WHERE repo = ? AND issue_number = ?') - .run(repo, issueNumber); - this.db - .prepare('DELETE FROM issue_locks WHERE repo = ? AND issue_number = ?') - .run(repo, issueNumber); - return result.changes; + return jobsRepo.deleteJobsForIssue(this.db, repo, issueNumber); } async addAuditLog(jobId: string | null, action: string, actor: string, detail: object): Promise { - this.db - .prepare('INSERT INTO audit_log (job_id, action, actor, detail) VALUES (?, ?, ?, ?)') - .run(jobId, action, actor, JSON.stringify(detail)); + return auditRepo.addAuditLog(this.db, jobId, action, actor, detail); } - oidcUpsert( - model: string, - id: string, - payload: object, - opts: { expiresAt?: number | null; grantId?: string | null; userCode?: string | null; uid?: string | null } = {}, - ): void { - this.db.prepare(` - INSERT INTO oidc_models (model, id, payload, grant_id, user_code, uid, expires_at, consumed_at) - VALUES (@model, @id, @payload, @grantId, @userCode, @uid, @expiresAt, NULL) - ON CONFLICT(model, id) DO UPDATE SET - payload = excluded.payload, - grant_id = excluded.grant_id, - user_code = excluded.user_code, - uid = excluded.uid, - expires_at = excluded.expires_at - `).run({ - model, id, - payload: JSON.stringify(payload), - grantId: opts.grantId ?? null, - userCode: opts.userCode ?? null, - uid: opts.uid ?? null, - expiresAt: opts.expiresAt ?? null, - }); + oidcUpsert(model: string, id: string, payload: object, opts: { expiresAt?: number | null; grantId?: string | null; userCode?: string | null; uid?: string | null } = {}): void { + return a2aRepo.oidcUpsert(this.db, model, id, payload, opts); } oidcFind(model: string, id: string): { payload: Record; consumedAt: number | null } | undefined { - const row = this.db.prepare( - 'SELECT payload, consumed_at FROM oidc_models WHERE model = ? AND id = ?', - ).get(model, id) as { payload: string; consumed_at: number | null } | undefined; - if (!row) return undefined; - return { payload: JSON.parse(row.payload), consumedAt: row.consumed_at }; + return a2aRepo.oidcFind(this.db, model, id); } oidcFindByUid(uid: string): { payload: Record } | undefined { - const row = this.db.prepare( - "SELECT payload FROM oidc_models WHERE model = 'Session' AND uid = ?", - ).get(uid) as { payload: string } | undefined; - return row ? { payload: JSON.parse(row.payload) } : undefined; + return a2aRepo.oidcFindByUid(this.db, uid); } oidcFindByUserCode(userCode: string): { payload: Record } | undefined { - const row = this.db.prepare( - 'SELECT payload FROM oidc_models WHERE user_code = ?', - ).get(userCode) as { payload: string } | undefined; - return row ? { payload: JSON.parse(row.payload) } : undefined; + return a2aRepo.oidcFindByUserCode(this.db, userCode); } oidcConsume(model: string, id: string, consumedAt: number): void { - this.db.prepare('UPDATE oidc_models SET consumed_at = ? WHERE model = ? AND id = ?') - .run(consumedAt, model, id); + return a2aRepo.oidcConsume(this.db, model, id, consumedAt); } oidcDestroy(model: string, id: string): void { - this.db.prepare('DELETE FROM oidc_models WHERE model = ? AND id = ?').run(model, id); + return a2aRepo.oidcDestroy(this.db, model, id); } oidcRevokeByGrantId(grantId: string): void { - this.db.prepare('DELETE FROM oidc_models WHERE grant_id = ?').run(grantId); + return a2aRepo.oidcRevokeByGrantId(this.db, grantId); } createA2aClient(row: A2aClientRow): void { - this.db.prepare(` - INSERT INTO a2a_clients - (client_id, name, redirect_uris, secret_hash, token_endpoint_auth_method, - agent_card_url, issuer, key_fingerprint, status, created_by) - VALUES (@clientId, @name, @redirectUris, @secretHash, @tokenEndpointAuthMethod, - @agentCardUrl, @issuer, @keyFingerprint, @status, @createdBy) - `).run({ - ...row, - redirectUris: JSON.stringify(row.redirectUris), - }); + return a2aRepo.createA2aClient(this.db, row); } getA2aClient(clientId: string): A2aClientRow | undefined { - const r = this.db.prepare('SELECT * FROM a2a_clients WHERE client_id = ?').get(clientId) as any; - if (!r) return undefined; - return { - clientId: r.client_id, name: r.name, redirectUris: JSON.parse(r.redirect_uris), - secretHash: r.secret_hash, tokenEndpointAuthMethod: r.token_endpoint_auth_method, - agentCardUrl: r.agent_card_url, issuer: r.issuer, keyFingerprint: r.key_fingerprint, - status: r.status, createdBy: r.created_by, createdAt: r.created_at, - }; + return a2aRepo.getA2aClient(this.db, clientId); } listA2aClients(): A2aClientRow[] { - const rows = this.db.prepare('SELECT * FROM a2a_clients ORDER BY created_at DESC').all() as any[]; - return rows.map(r => ({ - clientId: r.client_id, name: r.name, redirectUris: JSON.parse(r.redirect_uris), - secretHash: r.secret_hash, tokenEndpointAuthMethod: r.token_endpoint_auth_method, - agentCardUrl: r.agent_card_url, issuer: r.issuer, keyFingerprint: r.key_fingerprint, - status: r.status, createdBy: r.created_by, createdAt: r.created_at, - })); + return a2aRepo.listA2aClients(this.db); } setA2aClientStatus(clientId: string, status: 'active' | 'disabled'): void { - this.db.prepare('UPDATE a2a_clients SET status = ? WHERE client_id = ?').run(status, clientId); + return a2aRepo.setA2aClientStatus(this.db, clientId, status); } createA2aDelegation(row: A2aDelegationRow): void { - this.db.prepare(` - INSERT INTO a2a_delegations - (id, user_id, client_id, grant_id, granted_space_ids, granted_skills, audience, expires_at, revoked_at) - VALUES (@id, @userId, @clientId, @grantId, @grantedSpaceIds, @grantedSkills, @audience, @expiresAt, @revokedAt) - `).run({ - id: row.id, userId: row.userId, clientId: row.clientId, grantId: row.grantId, - grantedSpaceIds: JSON.stringify(row.grantedSpaceIds), - grantedSkills: JSON.stringify(row.grantedSkills), - audience: row.audience, expiresAt: row.expiresAt, revokedAt: row.revokedAt, - }); + return a2aRepo.createA2aDelegation(this.db, row); } private mapDelegation(r: any): A2aDelegationRow { - return { - id: r.id, userId: r.user_id, clientId: r.client_id, grantId: r.grant_id, - grantedSpaceIds: JSON.parse(r.granted_space_ids || '[]'), - grantedSkills: JSON.parse(r.granted_skills || '[]'), - audience: r.audience, expiresAt: r.expires_at, revokedAt: r.revoked_at, createdAt: r.created_at, - }; + return a2aRepo.mapDelegation(r); } getA2aDelegationByGrantId(grantId: string): A2aDelegationRow | undefined { - const r = this.db.prepare('SELECT * FROM a2a_delegations WHERE grant_id = ?').get(grantId); - return r ? this.mapDelegation(r) : undefined; + return a2aRepo.getA2aDelegationByGrantId(this.db, grantId); } listA2aDelegationsForUser(userId: string): A2aDelegationRow[] { - return (this.db.prepare('SELECT * FROM a2a_delegations WHERE user_id = ? ORDER BY created_at DESC').all(userId) as any[]) - .map(r => this.mapDelegation(r)); + return a2aRepo.listA2aDelegationsForUser(this.db, userId); } revokeA2aDelegation(id: string, revokedAtIso: string): void { - // revoked_at IS NULL ガード: 二重失効で元の失効時刻を上書きしない(監査の forensic 記録を守る) - this.db - .prepare('UPDATE a2a_delegations SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL') - .run(revokedAtIso, id); + return a2aRepo.revokeA2aDelegation(this.db, id, revokedAtIso); } getA2aDelegationById(id: string): A2aDelegationRow | undefined { - const r = this.db.prepare('SELECT * FROM a2a_delegations WHERE id = ?').get(id); - return r ? this.mapDelegation(r) : undefined; + return a2aRepo.getA2aDelegationById(this.db, id); } listLiveA2aDelegationsForClient(clientId: string, nowIso: string): A2aDelegationRow[] { - const rows = this.db.prepare( - `SELECT * FROM a2a_delegations - WHERE client_id = ? AND revoked_at IS NULL - AND (expires_at IS NULL OR expires_at > ?) - ORDER BY created_at DESC`, - ).all(clientId, nowIso); - return (rows as any[]).map((r) => this.mapDelegation(r)); + return a2aRepo.listLiveA2aDelegationsForClient(this.db, clientId, nowIso); } - listNonTerminalA2aTasksByGrant( - grantId: string, - limit = 500, - ): Array<{ id: string; jobId: string | null; payload: Record }> { - const rows = this.db.prepare( - `SELECT id, job_id, payload FROM a2a_tasks - WHERE grant_id = ? - AND (json_extract(payload, '$.status.state') NOT IN - ('completed','failed','canceled','rejected') - OR json_extract(payload, '$.status.state') IS NULL) - ORDER BY updated_at - LIMIT ?`, - ).all(grantId, limit); - return (rows as any[]).map((r) => ({ - id: r.id, - jobId: r.job_id ?? null, - payload: JSON.parse(r.payload ?? '{}'), - })); + listNonTerminalA2aTasksByGrant(grantId: string, limit = 500): Array<{ id: string; jobId: string | null; payload: Record }> { + return a2aRepo.listNonTerminalA2aTasksByGrant(this.db, grantId, limit); + } + + countNonTerminalA2aTasksByGrant(grantId: string): number { + return a2aRepo.countNonTerminalA2aTasksByGrant(this.db, grantId); } cancelA2aLinkedJob(jobId: string): boolean { - const res = this.db.prepare( - `UPDATE jobs SET status = 'cancelled', updated_at = datetime('now') - WHERE id = ? AND status IN - ('queued','dispatching','running','waiting_human','waiting_subtasks','retry')`, - ).run(jobId); - return res.changes > 0; + return a2aRepo.cancelA2aLinkedJob(this.db, jobId); } - listA2aDelegationsForUserWithClient( - userId: string, - ): Array { - const rows = this.db.prepare( - `SELECT d.*, c.name AS client_name, c.status AS client_status - FROM a2a_delegations d - LEFT JOIN a2a_clients c ON c.client_id = d.client_id - WHERE d.user_id = ? - ORDER BY d.created_at DESC`, - ).all(userId); - return (rows as any[]).map((r) => ({ - ...this.mapDelegation(r), - clientName: r.client_name ?? null, - clientStatus: r.client_status ?? null, - })); + listA2aDelegationsForUserWithClient(userId: string): Array { + return a2aRepo.listA2aDelegationsForUserWithClient(this.db, userId); } listA2aDelegationsAllWithClient(): Array< A2aDelegationRow & { userId: string; clientName: string | null; clientStatus: string | null } > { - const rows = this.db.prepare( - `SELECT d.*, c.name AS client_name, c.status AS client_status - FROM a2a_delegations d - LEFT JOIN a2a_clients c ON c.client_id = d.client_id - ORDER BY d.created_at DESC`, - ).all(); - return (rows as any[]).map((r) => ({ - ...this.mapDelegation(r), - clientName: r.client_name ?? null, - clientStatus: r.client_status ?? null, - })); + return a2aRepo.listA2aDelegationsAllWithClient(this.db); } getSpaceA2aSkills(spaceId: string): string[] { - const r = this.db.prepare('SELECT a2a_skills FROM spaces WHERE id = ?').get(spaceId) as { a2a_skills: string | null } | undefined; - if (!r || !r.a2a_skills) return []; - try { const v = JSON.parse(r.a2a_skills); return Array.isArray(v) ? v : []; } catch { return []; } + return spacesRepo.getSpaceA2aSkills(this.db, spaceId); } setSpaceA2aSkills(spaceId: string, skills: string[]): void { - this.db.prepare('UPDATE spaces SET a2a_skills = ? WHERE id = ?').run(JSON.stringify(skills), spaceId); + return spacesRepo.setSpaceA2aSkills(this.db, spaceId, skills); } saveA2aTask(row: { @@ -3647,1072 +521,219 @@ export class Repository { grantId?: string; actingUserId?: string; }): void { - this.db.prepare(` - INSERT INTO a2a_tasks (id, context_id, job_id, local_task_id, payload, updated_at, delegation_id, grant_id, acting_user_id) - VALUES (@id, @contextId, @jobId, @localTaskId, @payload, datetime('now'), @delegationId, @grantId, @actingUserId) - ON CONFLICT(id) DO UPDATE SET - context_id = excluded.context_id, job_id = excluded.job_id, - local_task_id = excluded.local_task_id, payload = excluded.payload, updated_at = datetime('now'), - delegation_id = excluded.delegation_id, grant_id = excluded.grant_id, acting_user_id = excluded.acting_user_id - `).run({ - id: row.id, - contextId: row.contextId, - jobId: row.jobId, - localTaskId: row.localTaskId, - payload: JSON.stringify(row.payload), - delegationId: row.delegationId ?? null, - grantId: row.grantId ?? null, - actingUserId: row.actingUserId ?? null, - }); + return a2aRepo.saveA2aTask(this.db, row); } loadA2aTask(id: string): { payload: Record } | undefined { - const r = this.db.prepare('SELECT payload FROM a2a_tasks WHERE id = ?').get(id) as { payload: string } | undefined; - return r ? { payload: JSON.parse(r.payload) } : undefined; + return a2aRepo.loadA2aTask(this.db, id); } getA2aTaskByJobId(jobId: string): { id: string; payload: Record } | undefined { - const r = this.db.prepare('SELECT id, payload FROM a2a_tasks WHERE job_id = ?').get(jobId) as { id: string; payload: string } | undefined; - return r ? { id: r.id, payload: JSON.parse(r.payload) } : undefined; + return a2aRepo.getA2aTaskByJobId(this.db, jobId); } listNonTerminalA2aTasks(limit = 500): Array<{ id: string; jobId: string | null; payload: Record }> { - const rows = this.db.prepare(` - SELECT id, job_id, payload FROM a2a_tasks - WHERE json_extract(payload, '$.status.state') NOT IN ('completed', 'failed', 'canceled', 'rejected') - OR json_extract(payload, '$.status.state') IS NULL - ORDER BY updated_at - LIMIT ? - `).all(limit) as Array<{ id: string; job_id: string | null; payload: string }>; - return rows.map(r => ({ id: r.id, jobId: r.job_id, payload: JSON.parse(r.payload) })); + return a2aRepo.listNonTerminalA2aTasks(this.db, limit); } async upsertWorkerNode(params: UpsertWorkerNodeParams): Promise { - const now = new Date().toISOString(); - this.db.prepare(` - INSERT INTO worker_nodes ( - worker_id, endpoint, enabled, healthy, profile_tags, task_class_tags, available_models, - inflight_jobs, max_concurrency, last_error, last_seen_at, updated_at - ) VALUES ( - @workerId, @endpoint, @enabled, @healthy, @roleTags, @roleTags, @availableModels, - @inflightJobs, @maxConcurrency, @lastError, @now, @now - ) - ON CONFLICT(worker_id) DO UPDATE SET - endpoint = excluded.endpoint, - enabled = excluded.enabled, - healthy = excluded.healthy, - profile_tags = excluded.profile_tags, - task_class_tags = excluded.task_class_tags, - available_models = excluded.available_models, - inflight_jobs = excluded.inflight_jobs, - max_concurrency = excluded.max_concurrency, - last_error = excluded.last_error, - last_seen_at = excluded.last_seen_at, - updated_at = excluded.updated_at - `).run({ - workerId: params.workerId, - endpoint: params.endpoint, - enabled: params.enabled ? 1 : 0, - healthy: params.healthy ? 1 : 0, - roleTags: encodeTags(params.roles), - availableModels: JSON.stringify(params.availableModels ?? []), - inflightJobs: params.inflightJobs ?? 0, - maxConcurrency: params.maxConcurrency ?? 1, - lastError: params.lastError ?? null, - now, - }); + return workerNodesRepo.upsertWorkerNode(this.db, params); } - async updateWorkerNodeHealth( - workerId: string, - updates: { healthy: boolean; lastError?: string | null; inflightJobs?: number; availableModels?: string[]; enabled?: boolean }, - ): Promise { - const setClauses = [ - 'healthy = @healthy', - 'last_error = @lastError', - "last_seen_at = @now", - "updated_at = @now", - ]; - const params: Record = { - workerId, - healthy: updates.healthy ? 1 : 0, - lastError: updates.lastError ?? null, - now: new Date().toISOString(), - }; - - if (updates.inflightJobs !== undefined) { - setClauses.push('inflight_jobs = @inflightJobs'); - params['inflightJobs'] = updates.inflightJobs; - } - if (updates.availableModels !== undefined) { - setClauses.push('available_models = @availableModels'); - params['availableModels'] = JSON.stringify(updates.availableModels); - } - if (updates.enabled !== undefined) { - setClauses.push('enabled = @enabled'); - params['enabled'] = updates.enabled ? 1 : 0; - } - - this.db.prepare(`UPDATE worker_nodes SET ${setClauses.join(', ')} WHERE worker_id = @workerId`).run(params); + async updateWorkerNodeHealth(workerId: string, updates: { healthy: boolean; lastError?: string | null; inflightJobs?: number; availableModels?: string[]; enabled?: boolean }): Promise { + return workerNodesRepo.updateWorkerNodeHealth(this.db, workerId, updates); } async getWorkerNode(workerId: string): Promise { - const row = this.db - .prepare('SELECT * FROM worker_nodes WHERE worker_id = ?') - .get(workerId) as WorkerNodeRow | undefined; - return row ? rowToWorkerNode(row) : null; + return workerNodesRepo.getWorkerNode(this.db, workerId); } async claimNextJob(workerId: string): Promise { - const row = this.db.prepare(` - UPDATE jobs - SET status = 'running', worker_id = ?, updated_at = datetime('now') - WHERE id = ( - SELECT j.id - FROM jobs j - JOIN worker_nodes w ON w.worker_id = ? - WHERE j.status = 'queued' - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 - AND NOT EXISTS ( - SELECT 1 FROM issue_locks il - WHERE il.repo = j.repo AND il.issue_number = j.issue_number - ) - ORDER BY j.created_at ASC - LIMIT 1 - ) - RETURNING * - `).get(workerId, workerId) as JobRow | undefined; - return row ? rowToJob(row) : null; + return jobsRepo.claimNextJob(this.db, workerId); } - /** - * リトライ待ちジョブの中から next_retry_at を過ぎたものを1件取得して running に遷移 - */ async claimNextRetryJob(workerId: string): Promise { - const row = this.db.prepare(` - UPDATE jobs - SET status = 'running', worker_id = ?, updated_at = datetime('now') - WHERE id = ( - SELECT j.id - FROM jobs j - JOIN worker_nodes w ON w.worker_id = ? - WHERE j.status = 'retry' - AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 - AND NOT EXISTS ( - SELECT 1 FROM issue_locks il - WHERE il.repo = j.repo AND il.issue_number = j.issue_number - ) - ORDER BY j.next_retry_at ASC - LIMIT 1 - ) - RETURNING * - `).get(workerId, workerId) as JobRow | undefined; - return row ? rowToJob(row) : null; + return jobsRepo.claimNextRetryJob(this.db, workerId); } - /** - * Read-only peek at the next job this worker WOULD claim (retry-priority, - * then oldest queued), without claiming it. Used by the idle-preferring - * claim gate to learn the next job's role before deciding whether to defer - * to an idler sibling. Mirrors the claimNext*Job WHERE clauses exactly. - */ async peekNextClaimable(workerId: string): Promise { - const retry = this.db.prepare(` - SELECT j.* - FROM jobs j - JOIN worker_nodes w ON w.worker_id = ? - WHERE j.status = 'retry' - AND replace(j.next_retry_at, 'T', ' ') <= datetime('now') - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 - AND NOT EXISTS ( - SELECT 1 FROM issue_locks il - WHERE il.repo = j.repo AND il.issue_number = j.issue_number - ) - ORDER BY j.next_retry_at ASC - LIMIT 1 - `).get(workerId) as JobRow | undefined; - if (retry) return rowToJob(retry); - - const queued = this.db.prepare(` - SELECT j.* - FROM jobs j - JOIN worker_nodes w ON w.worker_id = ? - WHERE j.status = 'queued' - AND w.enabled = 1 - AND w.healthy = 1 - AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0 - AND NOT EXISTS ( - SELECT 1 FROM issue_locks il - WHERE il.repo = j.repo AND il.issue_number = j.issue_number - ) - ORDER BY j.created_at ASC - LIMIT 1 - `).get(workerId) as JobRow | undefined; - return queued ? rowToJob(queued) : null; + return jobsRepo.peekNextClaimable(this.db, workerId); } async getJobsByStatus(status: JobStatus): Promise { - const rows = this.db - .prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC') - .all(status) as JobRow[]; - return rows.map(rowToJob); + return jobsRepo.getJobsByStatus(this.db, status); } async getLatestJobForIssue(repo: string, issueNumber: number): Promise { - const row = this.db - .prepare('SELECT * FROM jobs WHERE repo = ? AND issue_number = ? ORDER BY created_at DESC LIMIT 1') - .get(repo, issueNumber) as JobRow | undefined; - return row ? rowToJob(row) : null; + return jobsRepo.getLatestJobForIssue(this.db, repo, issueNumber); } - async updateJobContext( - jobId: string, - payload: { promptTokens: number; limitTokens: number } - ): Promise { - const updatedAt = new Date().toISOString(); - this.db - .prepare( - `UPDATE jobs - SET context_prompt_tokens = ?, - context_limit_tokens = ?, - context_updated_at = ? - WHERE id = ?` - ) - .run(payload.promptTokens, payload.limitTokens, updatedAt, jobId); + async updateJobContext(jobId: string, payload: { promptTokens: number; limitTokens: number }): Promise { + return jobsRepo.updateJobContext(this.db, jobId, payload); } - /** 起動時に孤立ジョブを回復 */ async recoverOrphanedJobs(): Promise { - const result = this.db - .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") - .run(); - if (result.changes > 0) { - this.db.prepare('DELETE FROM issue_locks').run(); - logger.warn(`Repository: recovered ${result.changes} orphaned jobs, cleared issue locks`); - } - // waiting_subtasks のジョブで全サブジョブが完了済みのものを再キュー - // 同一 issue_number に複数ジョブがある場合、最新のみで判定する - const subtaskRecovery = this.db.prepare(` - UPDATE jobs - SET status = 'queued', updated_at = datetime('now') - WHERE status = 'waiting_subtasks' - AND ( - SELECT COUNT(*) FROM ( - SELECT s.status, ROW_NUMBER() OVER ( - PARTITION BY s.issue_number - ORDER BY s.created_at DESC, s.rowid DESC - ) AS rn - FROM jobs s - WHERE s.parent_job_id = jobs.id - ) WHERE rn = 1 - AND status NOT IN ('succeeded','failed','cancelled') - ) = 0 - `).run(); - if (subtaskRecovery.changes > 0) { - logger.warn(`Repository: recovered ${subtaskRecovery.changes} waiting_subtasks jobs`); - } - return result.changes; + return jobsRepo.recoverOrphanedJobs(this.db); } - /** - * running/dispatching 状態のまま staleMinutes 以上 updated_at が更新されていないジョブを - * queued に戻す(ランタイム watchdog) - */ recoverStuckRunningJobs(staleMinutes: number): number { - const rows = this.db.prepare(` - UPDATE jobs - SET status = 'queued', - worker_id = NULL, - error_summary = 'Recovered: stuck in running for over ' || ? || ' minutes', - updated_at = datetime('now') - WHERE status IN ('running', 'dispatching') - AND updated_at < datetime('now', '-' || ? || ' minutes') - RETURNING id, repo - `).all(staleMinutes, staleMinutes) as Array<{ id: string; repo: string }>; - if (rows.length > 0) { - // issue ロックも解除 - for (const row of rows) { - this.db.prepare('DELETE FROM issue_locks WHERE job_id = ?').run(row.id); - } - logger.warn(`Repository: recovered ${rows.length} stuck jobs (stale > ${staleMinutes}min): ${rows.map(r => r.repo).join(', ')}`); - } - return rows.length; + return jobsRepo.recoverStuckRunningJobs(this.db, staleMinutes); } - /** running/dispatching 状態のジョブを全て queued に戻す(graceful shutdown 用) */ requeueRunningJobs(): number { - const result = this.db - .prepare("UPDATE jobs SET status = 'queued', worker_id = NULL, updated_at = datetime('now') WHERE status IN ('running', 'dispatching')") - .run(); - if (result.changes > 0) { - this.db.prepare('DELETE FROM issue_locks').run(); - logger.warn(`Repository: requeued ${result.changes} running jobs, cleared issue locks`); - } - return result.changes; + return jobsRepo.requeueRunningJobs(this.db); } getDistinctRepos(): string[] { - const rows = this.db.prepare('SELECT DISTINCT repo FROM jobs ORDER BY repo').all() as { repo: string }[]; - return rows.map(r => r.repo); + return jobsRepo.getDistinctRepos(this.db); } getJobsByRepo(repoName: string): Job[] { - const rows = this.db.prepare('SELECT * FROM jobs WHERE repo = ? ORDER BY created_at DESC').all(repoName) as JobRow[]; - return rows.map(rowToJob); + return jobsRepo.getJobsByRepo(this.db, repoName); } - /** Issue ごとに最新の Job だけを返す(カンバンUI用) */ getLatestJobsPerIssue(repoName: string): Job[] { - const rows = this.db.prepare(` - SELECT j.* - FROM jobs j - WHERE j.repo = ? - AND j.id = ( - SELECT j2.id - FROM jobs j2 - WHERE j2.repo = j.repo - AND j2.issue_number = j.issue_number - ORDER BY j2.updated_at DESC, j2.created_at DESC, j2.rowid DESC - LIMIT 1 - ) - ORDER BY j.updated_at DESC - `).all(repoName) as JobRow[]; - return rows.map(rowToJob); + return jobsRepo.getLatestJobsPerIssue(this.db, repoName); } - // Cascade a local_task visibility change to all jobs spawned for that task - // and their recursive subtask descendants (repo='subtask/'). - // Returns the number of job rows updated. - async updateJobsVisibilityForTask( - taskId: number, - updates: { visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }, - ): Promise { - const repoName = localTaskRepoName(taskId); - const now = new Date().toISOString(); - const result = this.db - .prepare(` - WITH RECURSIVE job_tree(id) AS ( - SELECT id FROM jobs WHERE repo = ? - UNION ALL - SELECT j.id FROM jobs j JOIN job_tree jt ON j.parent_job_id = jt.id - ) - UPDATE jobs - SET visibility = ?, - visibility_scope_org_id = ?, - updated_at = ? - WHERE id IN (SELECT id FROM job_tree) - `) - .run(repoName, updates.visibility, updates.visibilityScopeOrgId, now); - return result.changes; + async updateJobsVisibilityForTask(taskId: number, updates: { visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }): Promise { + return jobsRepo.updateJobsVisibilityForTask(this.db, taskId, updates); } async getSubJobs(parentJobId: string): Promise { - // 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す - // ROW_NUMBER() + rowid で同一 created_at でも一意に決定する - const rows = this.db - .prepare(` - SELECT * FROM ( - SELECT j.*, ROW_NUMBER() OVER ( - PARTITION BY j.issue_number - ORDER BY j.created_at DESC, j.rowid DESC - ) AS rn - FROM jobs j - WHERE j.parent_job_id = ? - ) WHERE rn = 1 - ORDER BY issue_number ASC - `) - .all(parentJobId) as JobRow[]; - return rows.map(rowToJob); + return jobsRepo.getSubJobs(this.db, parentJobId); } - /** - * 全サブジョブが終端状態なら親ジョブを再キューに戻す。 - * 再キューできた場合 true を返す。 - */ async requeueParentJobIfAllSubtasksDone(parentJobId: string): Promise { - // 同一 issue_number に複数ジョブがある場合、最新のもの(ROW_NUMBER=1)のみで判定する - const result = this.db.prepare(` - UPDATE jobs - SET status = 'queued', - updated_at = datetime('now') - WHERE id = ? - AND status = 'waiting_subtasks' - AND ( - SELECT COUNT(*) FROM ( - SELECT status, ROW_NUMBER() OVER ( - PARTITION BY issue_number - ORDER BY created_at DESC, rowid DESC - ) AS rn - FROM jobs - WHERE parent_job_id = ? - ) WHERE rn = 1 - AND status NOT IN ('succeeded', 'failed', 'cancelled') - ) = 0 - `).run(parentJobId, parentJobId); - return result.changes > 0; + return jobsRepo.requeueParentJobIfAllSubtasksDone(this.db, parentJobId); } async deleteLocalTask(taskId: number, worktreeDir?: string): Promise { - const repoName = localTaskRepoName(taskId); - - // タスク存在確認 & workspace_path / space_id / mode / runtime_dir を取得(削除前に必要) - const taskRow = this.db - .prepare('SELECT workspace_path, space_id, workspace_mode, runtime_dir FROM local_tasks WHERE id = ?') - .get(taskId) as { - workspace_path: string | null; - space_id: string | null; - workspace_mode: string | null; - runtime_dir: string | null; - } | undefined; - if (!taskRow) { - throw new Error(`deleteLocalTask: task ${taskId} not found`); - } - - // running/dispatching なジョブがある場合は削除を拒否 - const activeJob = this.db - .prepare("SELECT id FROM jobs WHERE repo = ? AND status IN ('running', 'dispatching') LIMIT 1") - .get(repoName) as { id: string } | undefined; - if (activeJob) { - throw new Error(`deleteLocalTask: task ${taskId} has an active job (${activeJob.id})`); - } - - // DB 操作をトランザクションで実行 - const deleteTransaction = this.db.transaction(() => { - this.db.prepare('DELETE FROM issue_locks WHERE repo = ?').run(repoName); - this.db.prepare('DELETE FROM jobs WHERE repo = ?').run(repoName); - this.db.prepare('DELETE FROM task_comment_index WHERE task_id = ?').run(taskId); - this.db.prepare('DELETE FROM local_tasks WHERE id = ?').run(taskId); - }); - deleteTransaction(); - - // ワークスペース削除(DB トランザクション外 — ロールバック不可のため)。 - // persistent スペースタスクの workspace_path は **スペース共有ツリー** - // (`{worktreeDir}/space/{spaceId}/files`) で、同じスペースの全会話が共有する。 - // 1 会話の削除でこれを rm すると他会話の output/input まで巻き添えで消える(データ消失) - // ため、共有ツリーは削除しない(DB 行のみ削除)。ephemeral/local の per-task - // ワークスペースは従来どおり削除する。会話固有の runtime_dir - // (`space/{spaceId}/runs/{taskId}` = logs/checklists/raw) は per-会話なので掃除する。 - - // パスヘルパー: normalize(末尾区切り除去)/ 実体 realpath / 末尾セグメント一致。 - const norm = (p: string): string => normalize(p).replace(/[/\\]+$/, ''); - const realOf = (p: string): string => { - const n = norm(p); - try { return existsSync(n) ? realpathSync(n) : n; } catch { return n; } - }; - const hasTail = (path: string, tail: string[]): boolean => { - const segs = path.split(/[/\\]+/); - return segs.length >= tail.length && tail.every((t, i) => segs[segs.length - tail.length + i] === t); - }; - - // 削除は **ホワイトリスト + worktreeDir アンカー** 方式。「共有ツリー形を blacklist して - // 例外を追う」と symlink/normalization のたびに抜け道が増えるため、逆に **設定された - // worktreeDir 配下に root され、かつシステムが作る per-task 形** のものだけを消す。 - // 判定は logical と realpath の **両方** で行い、どちらかが worktree 外/別実体を指す - // symlink なら消さない(共有ツリーや worktree 外データを巻き込まない)。 - // worktreeDir が無い経路(legacy/一部テスト)は安全側に倒して fs 削除を一切しない。 - if (worktreeDir) { - const root = realOf(worktreeDir); - const rooted = (real: string): boolean => real === root || real.startsWith(root + sep); - - // workspace_path: per-task は ephemeral/{taskId} または旧 local/{taskId} のみ。共有ツリー - // (space/{id}/files)・worktree 外・symlink リダイレクトは保持。 - if (taskRow.workspace_path) { - const wsReal = realOf(taskRow.workspace_path); - const perTaskShape = (s: string): boolean => - hasTail(s, ['ephemeral', String(taskId)]) || hasTail(s, ['local', String(taskId)]); - if (rooted(wsReal) && perTaskShape(wsReal) && perTaskShape(norm(taskRow.workspace_path)) && existsSync(wsReal)) { - rmSync(wsReal, { recursive: true, force: true }); - } else { - logger.info(`Repository: deleteLocalTask kept non-per-task workspace ${taskRow.workspace_path} (task ${taskId})`); - } - } - - // runtime_dir: 会話固有 (space/<任意>/runs/{taskId})。worktree 配下に root され、logical/real - // 両方が runs/{taskId} 形のときだけ掃除(共有 files を指す symlink は real 形が一致せず除外)。 - if (taskRow.runtime_dir) { - const rtReal = realOf(taskRow.runtime_dir); - const runsShape = (s: string): boolean => - hasTail(s, ['runs', String(taskId)]) && s.split(/[/\\]+/).slice(-4)[0] === 'space'; - if (rooted(rtReal) && runsShape(rtReal) && runsShape(norm(taskRow.runtime_dir)) && existsSync(rtReal)) { - rmSync(rtReal, { recursive: true, force: true }); - } else { - logger.info(`Repository: deleteLocalTask kept runtime_dir ${taskRow.runtime_dir} (task ${taskId})`); - } - } - } - - logger.info(`Repository: deleted local task ${taskId}`); + return localTasksRepo.deleteLocalTask(this.db, taskId, worktreeDir); } - /** - * 実行中のジョブをキャンセル状態に変更する。 - * running または dispatching 状態のジョブのみ対象。 - * 戻り値: キャンセル対象ジョブが見つかったら true、見つからなかったら false。 - */ requestJobCancel(jobId: string): boolean { - const result = this.db.prepare(` - UPDATE jobs - SET status = 'cancelled', updated_at = datetime('now') - WHERE id = ? AND status IN ('running', 'dispatching') - `).run(jobId); - return result.changes > 0; + return jobsRepo.requestJobCancel(this.db, jobId); } - // ── Scheduled Tasks ────────────────────────────────────────── - private mapScheduledTask(row: any): ScheduledTask { - const rawVisibility = row.visibility; - const visibility: ScheduledTask['visibility'] = - rawVisibility === 'org' || rawVisibility === 'public' ? rawVisibility : 'private'; - return { - id: row.id, - title: row.title, - body: row.body, - pieceName: row.piece_name, - profile: row.profile, - outputFormat: row.output_format, - cronExpression: row.cron_expression, - nextRunAt: utc(row.next_run_at), - lastRunAt: utc(row.last_run_at), - lastJobId: row.last_job_id, - isActive: row.is_active === 1, - ownerId: row.owner_id ?? null, - ownerName: row.owner_name ?? null, - visibility, - visibilityScopeOrgId: row.visibility_scope_org_id ?? null, - visibilityScopeOrgName: row.visibility_scope_org_name ?? null, - browserSessionProfileId: row.browser_session_profile_id ?? null, - taskKind: row.task_kind === 'script' ? 'script' : 'agent', - scriptName: row.script_name ?? null, - scriptParams: row.script_params ?? null, - spaceId: row.space_id ?? null, - createdAt: utc(row.created_at), - updatedAt: utc(row.updated_at), - }; + return scheduledTasksRepo.mapScheduledTask(row); } async createScheduledTask(params: CreateScheduledTaskParams): Promise { - const result = this.db - .prepare( - `INSERT INTO scheduled_tasks (title, body, piece_name, profile, output_format, cron_expression, next_run_at, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, script_name, script_params, space_id) - VALUES (@title, @body, @pieceName, @profile, @outputFormat, @cronExpression, @nextRunAt, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @scriptName, @scriptParams, @spaceId)` - ) - .run({ - title: params.title ?? null, - body: params.body, - pieceName: params.pieceName ?? 'auto', - profile: params.profile ?? 'auto', - outputFormat: params.outputFormat ?? 'markdown', - cronExpression: params.cronExpression, - nextRunAt: params.nextRunAt, - ownerId: params.ownerId ?? null, - visibility: params.visibility ?? 'private', - visibilityScopeOrgId: params.visibilityScopeOrgId ?? null, - browserSessionProfileId: params.browserSessionProfileId ?? null, - taskKind: params.taskKind ?? 'agent', - scriptName: params.scriptName ?? null, - scriptParams: params.scriptParams ?? null, - spaceId: params.spaceId ?? null, - }); - const task = this.getScheduledTaskSync(Number(result.lastInsertRowid)); - if (!task) throw new Error('createScheduledTask: failed to load inserted task'); - return task; + return scheduledTasksRepo.createScheduledTask(this.db, params); } private getScheduledTaskSync(id: number): ScheduledTask | null { - const row = this.db - .prepare(` - SELECT st.*, - ${SCHEDULED_TASK_DISPLAY_SELECT} - FROM scheduled_tasks st - ${SCHEDULED_TASK_DISPLAY_JOIN} - WHERE st.id = ? - `) - .get(id) as any; - return row ? this.mapScheduledTask(row) : null; + return scheduledTasksRepo.getScheduledTaskSync(this.db, id); } async getScheduledTask(id: number, opts?: { viewer?: Express.User }): Promise { - const viewerClause = opts?.viewer - ? buildVisibilityWhere(opts.viewer, 'st', { spaceColumn: 'space_id' }) - : { clause: '1=1', params: [] as unknown[] }; - const row = this.db - .prepare(` - SELECT st.*, - ${SCHEDULED_TASK_DISPLAY_SELECT} - FROM scheduled_tasks st - ${SCHEDULED_TASK_DISPLAY_JOIN} - WHERE st.id = ? AND ${viewerClause.clause} - `) - .get(id, ...viewerClause.params) as any; - return row ? this.mapScheduledTask(row) : null; + return scheduledTasksRepo.getScheduledTask(this.db, id, opts); } async listScheduledTasks(filter?: { viewer?: Express.User; spaceId?: string | null }): Promise { - const conditions: string[] = []; - const queryParams: unknown[] = []; - if (filter?.viewer) { - const w = buildVisibilityWhere(filter.viewer, 'st', { spaceColumn: 'space_id' }); - conditions.push(w.clause); - queryParams.push(...w.params); - } - // Optional per-space scoping (the space's "Schedules" tab). null = only the - // space-less (personal) schedules; a string = exactly that space. - if (filter?.spaceId !== undefined) { - if (filter.spaceId === null) { - conditions.push('st.space_id IS NULL'); - } else { - conditions.push('st.space_id = ?'); - queryParams.push(filter.spaceId); - } - } - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - const rows = this.db - .prepare(` - SELECT st.*, - ${SCHEDULED_TASK_DISPLAY_SELECT} - FROM scheduled_tasks st - ${SCHEDULED_TASK_DISPLAY_JOIN} - ${whereClause} - ORDER BY st.created_at DESC - `) - .all(...queryParams) as any[]; - return rows.map(r => this.mapScheduledTask(r)); + return scheduledTasksRepo.listScheduledTasks(this.db, filter); } - /** - * due なスケジュールタスクをアトミックに claim して返す。 - * BEGIN IMMEDIATE で書き込みロックを即座に取得し、 - * 他のスケジューラーインスタンスとの重複実行を防止する。 - * claim されたタスクは next_run_at が遠い未来に設定されるため、 - * 他インスタンスに再取得されない。 - * 呼び出し側が実行後に正しい next_run_at を再設定する。 - */ async getScheduledTasksDue(): Promise { - // 十分遠い未来(claim マーカー) - const claimMarker = '9999-12-31 23:59:59'; - - // BEGIN IMMEDIATE: トランザクション開始時に RESERVED ロックを取得し、 - // 他の書き込みトランザクションとの競合を防ぐ - const txn = this.db.transaction(() => { - const rows = this.db - .prepare( - `SELECT * FROM scheduled_tasks - WHERE is_active = 1 AND next_run_at <= datetime('now') - ORDER BY next_run_at ASC` - ) - .all() as any[]; - - if (rows.length === 0) return []; - - // claim: next_run_at を遠い未来に設定して他インスタンスからの重複取得を防止 - const ids = rows.map((r: any) => r.id); - this.db - .prepare( - `UPDATE scheduled_tasks - SET next_run_at = ?, updated_at = datetime('now') - WHERE id IN (${ids.map(() => '?').join(',')})` - ) - .run(claimMarker, ...ids); - - return rows; - }); - - const rows = txn.immediate(); - return rows.map((r: any) => this.mapScheduledTask(r)); + return scheduledTasksRepo.getScheduledTasksDue(this.db); } async updateScheduledTask(id: number, params: UpdateScheduledTaskParams): Promise { - const sets: string[] = []; - const values: Record = { id }; - - if (params.title !== undefined) { sets.push('title = @title'); values.title = params.title; } - if (params.body !== undefined) { sets.push('body = @body'); values.body = params.body; } - if (params.pieceName !== undefined) { sets.push('piece_name = @pieceName'); values.pieceName = params.pieceName; } - if (params.profile !== undefined) { sets.push('profile = @profile'); values.profile = params.profile; } - if (params.outputFormat !== undefined) { sets.push('output_format = @outputFormat'); values.outputFormat = params.outputFormat; } - if (params.cronExpression !== undefined) { sets.push('cron_expression = @cronExpression'); values.cronExpression = params.cronExpression; } - if (params.nextRunAt !== undefined) { sets.push('next_run_at = @nextRunAt'); values.nextRunAt = params.nextRunAt; } - if (params.lastRunAt !== undefined) { sets.push('last_run_at = @lastRunAt'); values.lastRunAt = params.lastRunAt; } - if (params.lastJobId !== undefined) { sets.push('last_job_id = @lastJobId'); values.lastJobId = params.lastJobId; } - if (params.isActive !== undefined) { sets.push('is_active = @isActive'); values.isActive = params.isActive ? 1 : 0; } - if (params.visibility !== undefined) { sets.push('visibility = @visibility'); values.visibility = params.visibility; } - if (params.visibilityScopeOrgId !== undefined) { - sets.push('visibility_scope_org_id = @visibilityScopeOrgId'); - values.visibilityScopeOrgId = params.visibilityScopeOrgId; - } - if (params.browserSessionProfileId !== undefined) { - sets.push('browser_session_profile_id = @browserSessionProfileId'); - values.browserSessionProfileId = params.browserSessionProfileId; - } - if (params.taskKind !== undefined) { - sets.push('task_kind = @taskKind'); - values.taskKind = params.taskKind; - } - if (params.scriptName !== undefined) { - sets.push('script_name = @scriptName'); - values.scriptName = params.scriptName; - } - if (params.scriptParams !== undefined) { - sets.push('script_params = @scriptParams'); - values.scriptParams = params.scriptParams; - } - - if (sets.length === 0) return this.getScheduledTaskSync(id); - - sets.push("updated_at = datetime('now')"); - this.db.prepare(`UPDATE scheduled_tasks SET ${sets.join(', ')} WHERE id = @id`).run(values); - return this.getScheduledTaskSync(id); + return scheduledTasksRepo.updateScheduledTask(this.db, id, params); } async deleteScheduledTask(id: number): Promise { - const result = this.db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); - return result.changes > 0; - } - - // ── User CRUD ──────────────────────────────────────────────────── - - private rowToUser(row: UserRow): User { - return rowToUser(row); + return scheduledTasksRepo.deleteScheduledTask(this.db, id); } createUser(params: CreateUserParams): User { - const id = uuidv4(); - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) - VALUES (@id, @email, @name, @avatarUrl, @role, @status, @now, @now)` - ) - .run({ - id, - email: params.email, - name: params.name, - avatarUrl: params.avatarUrl ?? null, - role: params.role, - status: params.status, - now, - }); - const user = this.getUserById(id); - if (!user) throw new Error(`createUser: failed to retrieve created user ${id}`); - return user; + return usersRepo.createUser(this.db, params); } - /** - * Ensure the synthetic 'local' user row exists. No-auth single-user - * deployments own per-user rows under the id 'local' (tasks, jobs, SSH - * connections, DEKs, …). Many of those tables FK to users(id) with - * foreign_keys ON, so the row must exist or the inserts fail — e.g. - * ssh_user_deks → SSH connection creation returned create_failed. - * Idempotent (INSERT OR IGNORE), so it is safe to call on every startup. - * role='admin' mirrors the synthetic 'local' user the HTTP layer injects - * for task-visibility routes in no-auth mode. - */ ensureLocalUser(): void { - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT OR IGNORE INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) - VALUES ('local', 'local@localhost', 'local', NULL, 'admin', 'active', @now, @now)` - ) - .run({ now }); + return usersRepo.ensureLocalUser(this.db); } - // ── Local auth (email + password) ───────────────────────────────────── - - /** scrypt hash with a fresh per-user salt. Overwrites any existing credential. */ setLocalPassword(userId: string, plainPassword: string): void { - const salt = randomBytes(16).toString('hex'); - const hash = scryptSync(plainPassword, salt, 64).toString('hex'); - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT INTO local_credentials (user_id, password_hash, salt, updated_at) - VALUES (@userId, @hash, @salt, @now) - ON CONFLICT(user_id) DO UPDATE SET password_hash=@hash, salt=@salt, updated_at=@now`, - ) - .run({ userId, hash, salt, now }); + return usersRepo.setLocalPassword(this.db, userId, plainPassword); } - /** Constant-time verify. False when the user has no local credential. */ verifyLocalPassword(userId: string, plainPassword: string): boolean { - const row = this.db - .prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id = ?') - .get(userId) as { password_hash: string; salt: string } | undefined; - if (!row) return false; - const expected = Buffer.from(row.password_hash, 'hex'); - const actual = scryptSync(plainPassword, row.salt, expected.length); - return expected.length === actual.length && timingSafeEqual(expected, actual); + return usersRepo.verifyLocalPassword(this.db, userId, plainPassword); } hasLocalCredential(userId: string): boolean { - return !!this.db.prepare('SELECT 1 FROM local_credentials WHERE user_id = ?').get(userId); + return usersRepo.hasLocalCredential(this.db, userId); } - /** - * Create a brand-new local account (self-signup or admin-created). The email - * MUST be unused: attaching a password to an existing account would be an - * account-takeover vector, so we reject instead of linking. Linking a local - * credential to an existing OAuth account is a separate, authenticated action - * (not v1 signup). - */ createLocalUser(params: CreateLocalUserParams): User { - if (this.getUserByEmail(params.email)) { - throw new Error(`createLocalUser: a user with email ${params.email} already exists`); - } - const user = this.createUser({ - email: params.email, - name: params.name ?? params.email, - role: params.role, - status: params.status, - }); - this.db - .prepare( - `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) - VALUES (@id, @userId, 'local', @providerId, @now)`, - ) - .run({ id: uuidv4(), userId: user.id, providerId: params.email, now: new Date().toISOString() }); - this.setLocalPassword(user.id, params.password); - return user; + return usersRepo.createLocalUser(this.db, params); } - /** - * Idempotently seed the shared system admin under the fixed id `local` — the - * same owner the no-auth path synthesizes. This makes all pre-existing - * `local`-owned data belong to the logged-in admin once local auth is turned - * on, and lets an existing no-auth deployment gain a login mid-stream. - * Re-running updates the password and keeps role=admin/status=active. - */ upsertLocalSystemAdmin(params: { email: string; password: string; name?: string }): User { - const LOCAL_ID = 'local'; - const now = new Date().toISOString(); - const existing = this.getUserById(LOCAL_ID); - if (!existing) { - this.db - .prepare( - `INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) - VALUES (@id, @email, @name, NULL, 'admin', 'active', @now, @now)`, - ) - .run({ id: LOCAL_ID, email: params.email, name: params.name ?? 'Local Admin', now }); - } else { - this.db - .prepare(`UPDATE users SET email=@email, role='admin', status='active', updated_at=@now WHERE id=@id`) - .run({ id: LOCAL_ID, email: params.email, now }); - } - this.db - .prepare( - `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) - VALUES (@id, @userId, 'local', @providerId, @now)`, - ) - .run({ id: uuidv4(), userId: LOCAL_ID, providerId: params.email, now }); - this.setLocalPassword(LOCAL_ID, params.password); - const user = this.getUserById(LOCAL_ID); - if (!user) throw new Error('upsertLocalSystemAdmin: failed to retrieve local admin'); - return user; + return usersRepo.upsertLocalSystemAdmin(this.db, params); } - // ── Local organizations ─────────────────────────────────────────────── - private rowToLocalOrg(r: { id: string; name: string; created_by: string | null; created_at: string }): LocalOrg { - return { id: r.id, name: r.name, createdBy: r.created_by, createdAt: r.created_at }; + return usersRepo.rowToLocalOrg(r); } - /** Create a local org. id is prefixed `lorg:` so it never collides with a - * Gitea numeric org id (both live in visibility_scope_org_id). */ createLocalOrg(name: string, createdBy: string | null): LocalOrg { - const id = `lorg:${uuidv4()}`; - const now = new Date().toISOString(); - this.db - .prepare(`INSERT INTO local_orgs (id, name, created_by, created_at) VALUES (@id, @name, @createdBy, @now)`) - .run({ id, name, createdBy, now }); - return { id, name, createdBy, createdAt: now }; + return usersRepo.createLocalOrg(this.db, name, createdBy); } getLocalOrg(id: string): LocalOrg | null { - const r = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs WHERE id = ?').get(id) as - | { id: string; name: string; created_by: string | null; created_at: string } - | undefined; - return r ? this.rowToLocalOrg(r) : null; + return usersRepo.getLocalOrg(this.db, id); } listLocalOrgs(): LocalOrg[] { - const rows = this.db.prepare('SELECT id, name, created_by, created_at FROM local_orgs ORDER BY name COLLATE NOCASE').all() as Array<{ id: string; name: string; created_by: string | null; created_at: string }>; - return rows.map(r => this.rowToLocalOrg(r)); + return usersRepo.listLocalOrgs(this.db); } renameLocalOrg(id: string, name: string): void { - this.db.prepare('UPDATE local_orgs SET name = ? WHERE id = ?').run(name, id); + return usersRepo.renameLocalOrg(this.db, id, name); } - /** Tables carrying `visibility_scope_org_id` (org-scoped resources). */ - private static readonly ORG_SCOPED_TABLES = ['local_tasks', 'scheduled_tasks', 'jobs']; - - /** - * Delete a local org. Members cascade via FK. Resources scoped to this org - * (visibility='org', visibility_scope_org_id=id) would become invisible to - * everyone once the org is gone — so first downgrade them to 'private' - * (owner + admin can still see them; no data loss). Atomic. - */ deleteLocalOrg(id: string): void { - const tx = this.db.transaction((orgId: string) => { - for (const table of Repository.ORG_SCOPED_TABLES) { - this.db - .prepare(`UPDATE ${table} SET visibility = 'private', visibility_scope_org_id = NULL WHERE visibility_scope_org_id = ?`) - .run(orgId); - } - this.db.prepare('DELETE FROM local_orgs WHERE id = ?').run(orgId); - }); - tx(id); + return usersRepo.deleteLocalOrg(this.db, id); } - /** Add or update a member (idempotent — re-add updates the role). */ addOrgMember(orgId: string, userId: string, role: string = 'member'): void { - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT INTO local_org_members (org_id, user_id, role, added_at) - VALUES (@orgId, @userId, @role, @now) - ON CONFLICT(org_id, user_id) DO UPDATE SET role=@role`, - ) - .run({ orgId, userId, role, now }); + return usersRepo.addOrgMember(this.db, orgId, userId, role); } removeOrgMember(orgId: string, userId: string): void { - this.db.prepare('DELETE FROM local_org_members WHERE org_id = ? AND user_id = ?').run(orgId, userId); + return usersRepo.removeOrgMember(this.db, orgId, userId); } listOrgMembers(orgId: string): LocalOrgMember[] { - const rows = this.db - .prepare('SELECT user_id, role FROM local_org_members WHERE org_id = ? ORDER BY added_at') - .all(orgId) as Array<{ user_id: string; role: string }>; - return rows.map(r => ({ userId: r.user_id, role: r.role })); + return usersRepo.listOrgMembers(this.db, orgId); } - /** Orgs a user belongs to — merged into session.orgIds so the existing - * provider-agnostic 'org' visibility (buildVisibilityWhere) covers them. */ listUserLocalOrgs(userId: string): Array<{ orgId: string; name: string }> { - const rows = this.db - .prepare( - `SELECT o.id AS org_id, o.name AS name - FROM local_org_members m JOIN local_orgs o ON o.id = m.org_id - WHERE m.user_id = ? ORDER BY o.name COLLATE NOCASE`, - ) - .all(userId) as Array<{ org_id: string; name: string }>; - return rows.map(r => ({ orgId: r.org_id, name: r.name })); + return usersRepo.listUserLocalOrgs(this.db, userId); } getUserById(id: string): User | null { - const row = this.db - .prepare('SELECT * FROM users WHERE id = ?') - .get(id) as UserRow | undefined; - return row ? this.rowToUser(row) : null; + return usersRepo.getUserById(this.db, id); } getUserByEmail(email: string): User | null { - const row = this.db - .prepare('SELECT * FROM users WHERE email = ?') - .get(email) as UserRow | undefined; - return row ? this.rowToUser(row) : null; + return usersRepo.getUserByEmail(this.db, email); } findOrCreateUserByOAuth(params: FindOrCreateByOAuthParams): User { - // 1. Check if oauth_account already exists - const existing = this.db - .prepare('SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_id = ?') - .get(params.provider, params.providerId) as { user_id: string } | undefined; - - if (existing) { - const user = this.getUserById(existing.user_id); - if (!user) throw new Error(`findOrCreateUserByOAuth: user ${existing.user_id} not found`); - // Sync mutable profile fields from the provider on every re-login so - // existing users whose name was missing on first login pick it up once - // their Gitea profile is populated. Email upgrade only applies when the - // dummy @gitea.local placeholder is being replaced. - const patch: { email?: string; name?: string; avatarUrl?: string | null } = {}; - if (user.email.endsWith('@gitea.local') && !params.email.endsWith('@gitea.local')) { - patch.email = params.email; - } - if (params.name && params.name !== user.name) patch.name = params.name; - if (params.avatarUrl !== undefined && params.avatarUrl !== user.avatarUrl) { - patch.avatarUrl = params.avatarUrl; - } - if (Object.keys(patch).length > 0) { - this.updateUser(user.id, patch); - const refreshed = this.getUserById(user.id); - if (refreshed) return refreshed; - } - return user; - } - - // 2. Check if user exists by email - let user = this.getUserByEmail(params.email); - - if (!user) { - // 3. Create new user with status=pending - user = this.createUser({ - email: params.email, - name: params.name, - role: 'user', - status: 'pending', - avatarUrl: params.avatarUrl, - }); - } - - // 4. Link oauth_account to user - const oauthId = uuidv4(); - const now = new Date().toISOString(); - this.db - .prepare( - `INSERT OR IGNORE INTO oauth_accounts (id, user_id, provider, provider_id, created_at) - VALUES (@id, @userId, @provider, @providerId, @now)` - ) - .run({ - id: oauthId, - userId: user.id, - provider: params.provider, - providerId: params.providerId, - now, - }); - - return user; + return usersRepo.findOrCreateUserByOAuth(this.db, params); } listUsers(): User[] { - const rows = this.db - .prepare('SELECT * FROM users ORDER BY created_at ASC') - .all() as UserRow[]; - return rows.map(row => this.rowToUser(row)); + return usersRepo.listUsers(this.db); } - /** - * Active users who share at least one organization with the given org id - * set (Gitea org cache `user_gitea_orgs` OR local org membership - * `local_org_members`). Used by the member-invite picker so a requester only - * sees collaborators inside their own org(s) — exposing the full user list is - * a privacy leak. Empty `orgIds` → empty result. Deduplicated by user id. - */ listActiveUsersInOrgs(orgIds: string[]): User[] { - if (orgIds.length === 0) return []; - const placeholders = orgIds.map(() => '?').join(', '); - const rows = this.db - .prepare( - `SELECT u.* FROM users u - WHERE u.status = 'active' AND u.id IN ( - SELECT user_id FROM user_gitea_orgs WHERE org_id IN (${placeholders}) - UNION - SELECT user_id FROM local_org_members WHERE org_id IN (${placeholders}) - ) - ORDER BY u.created_at ASC`, - ) - .all(...orgIds, ...orgIds) as UserRow[]; - return rows.map(row => this.rowToUser(row)); + return usersRepo.listActiveUsersInOrgs(this.db, orgIds); } updateUser(id: string, updates: { @@ -4724,136 +745,34 @@ export class Repository { defaultVisibility?: 'private' | 'org' | 'public'; defaultVisibilityOrgId?: string | null; }): void { - const setClauses: string[] = ["updated_at = datetime('now')"]; - const params: Record = { id }; - - if (updates.status !== undefined) { - setClauses.push('status = @status'); - params['status'] = updates.status; - } - if (updates.role !== undefined) { - setClauses.push('role = @role'); - params['role'] = updates.role; - } - if (updates.email !== undefined) { - setClauses.push('email = @email'); - params['email'] = updates.email; - } - if (updates.name !== undefined) { - setClauses.push('name = @name'); - params['name'] = updates.name; - } - if (updates.avatarUrl !== undefined) { - setClauses.push('avatar_url = @avatar_url'); - params['avatar_url'] = updates.avatarUrl; - } - if (updates.defaultVisibility !== undefined) { - setClauses.push('default_visibility = @default_visibility'); - params['default_visibility'] = updates.defaultVisibility; - } - if (updates.defaultVisibilityOrgId !== undefined) { - setClauses.push('default_visibility_org_id = @default_visibility_org_id'); - params['default_visibility_org_id'] = updates.defaultVisibilityOrgId; - } - - if (setClauses.length === 1) return; - - this.db - .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`) - .run(params); + return usersRepo.updateUser(this.db, id, updates); } deleteUser(id: string): void { - // Never delete the shared `local` system/admin user: it is the no-auth - // fallback owner and owns all single-user-mode data. Deleting it would - // break no-auth mode and orphan every `local`-owned task/job/folder. - if (id === 'local') { - throw new Error('cannot delete the local/system user'); - } - this.db.prepare('DELETE FROM users WHERE id = ?').run(id); + return usersRepo.deleteUser(this.db, id); } deleteSessionsByUserId(userId: string): void { - // Sessions store passport user info as JSON in sess column - // Delete sessions where sess contains the user id - const rows = this.db - .prepare('SELECT sid, sess FROM sessions') - .all() as Array<{ sid: string; sess: string }>; - - const toDelete: string[] = []; - for (const row of rows) { - try { - const sess = JSON.parse(row.sess) as Record; - const passport = sess['passport'] as Record | undefined; - if (passport && passport['user'] === userId) { - toDelete.push(row.sid); - } - } catch { - // ignore parse errors - } - } - - if (toDelete.length > 0) { - const placeholders = toDelete.map(() => '?').join(', '); - this.db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...toDelete); - } + return usersRepo.deleteSessionsByUserId(this.db, userId); } replaceUserGiteaOrgs(userId: string, orgs: GiteaOrgInput[]): void { - const tx = this.db.transaction((uid: string, items: GiteaOrgInput[]) => { - this.db.prepare('DELETE FROM user_gitea_orgs WHERE user_id = ?').run(uid); - const insert = this.db.prepare( - 'INSERT INTO user_gitea_orgs (user_id, org_id, org_name) VALUES (?, ?, ?)' - ); - for (const o of items) insert.run(uid, o.orgId, o.orgName); - }); - tx(userId, orgs); + return usersRepo.replaceUserGiteaOrgs(this.db, userId, orgs); } listUserGiteaOrgs(userId: string): GiteaOrg[] { - const rows = this.db - .prepare('SELECT org_id, org_name, fetched_at FROM user_gitea_orgs WHERE user_id = ? ORDER BY org_name ASC') - .all(userId) as Array<{ org_id: string; org_name: string; fetched_at: string }>; - return rows.map(r => ({ orgId: r.org_id, orgName: r.org_name, fetchedAt: r.fetched_at })); + return usersRepo.listUserGiteaOrgs(this.db, userId); } - // ── Reflection piece-edit cooldown ────────────────────────────────────────── - - /** - * Records that the reflection pipeline wrote a new version of pieceName for - * userId. snapshotId ties the edit back to the snapshot that triggered it. - */ recordPieceEdit(userId: string, pieceName: string, snapshotId: string): void { - this.db.prepare( - `INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) - VALUES (?, ?, ?, ?)` - ).run(userId, pieceName, snapshotId, Date.now()); + return reflectionRepo.recordPieceEdit(this.db, userId, pieceName, snapshotId); } - /** - * Returns the number of piece edits for (userId, pieceName) that occurred - * within the last sinceMs milliseconds. Used by the cooldown gate in - * piece-writer.ts to prevent over-editing the same piece. - */ countRecentPieceEdits(userId: string, pieceName: string, sinceMs: number): number { - return (this.db.prepare( - `SELECT COUNT(*) AS c FROM reflection_piece_edits - WHERE user_id = ? AND piece_name = ? AND created_at > ?` - ).get(userId, pieceName, Date.now() - sinceMs) as { c: number }).c; + return reflectionRepo.countRecentPieceEdits(this.db, userId, pieceName, sinceMs); } - // ── Reflection metrics ─────────────────────────────────────────────────────── - - /** - * Insert one row into reflection_metrics, optionally bundling a - * reflection_piece_edits row in the same transaction. - * - * When pieceEdit is supplied the two inserts are wrapped in a single - * db.transaction() so the tables stay consistent even if the process - * crashes between them. - */ - recordReflectionRun( - metric: { + recordReflectionRun(metric: { reflection_job_id: string; original_job_id: string | null; user_id: string; @@ -4864,38 +783,11 @@ export class Repository { tokens_in: number; tokens_out: number; duration_ms: number; - }, - pieceEdit?: { pieceName: string; snapshotId: string }, - ): void { - const now = Date.now(); - const insertMetric = this.db.prepare(` - INSERT INTO reflection_metrics - (reflection_job_id, original_job_id, user_id, piece_name, outcome, - memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at) - VALUES - (@reflection_job_id, @original_job_id, @user_id, @piece_name, @outcome, - @memory_changes, @piece_edited, @tokens_in, @tokens_out, @duration_ms, @created_at) - `); - - if (pieceEdit) { - const insertEdit = this.db.prepare(` - INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at) - VALUES (?, ?, ?, ?) - `); - this.db.transaction(() => { - insertMetric.run({ ...metric, created_at: now }); - insertEdit.run(metric.user_id, pieceEdit.pieceName, pieceEdit.snapshotId, now); - })(); - } else { - insertMetric.run({ ...metric, created_at: now }); - } + }, pieceEdit?: { pieceName: string; snapshotId: string }): void { + return reflectionRepo.recordReflectionRun(this.db, metric, pieceEdit); } - /** - * Convenience alias for callers that don't need the bundled pieceEdit path. - */ - recordReflectionMetric( - row: { + recordReflectionMetric(row: { reflection_job_id: string; original_job_id: string | null; user_id: string; @@ -4906,19 +798,11 @@ export class Repository { tokens_in: number; tokens_out: number; duration_ms: number; - }, - ): void { - this.recordReflectionRun(row); + }): void { + return reflectionRepo.recordReflectionMetric(this.db, row); } - /** - * Aggregate reflection metrics for a user since sinceMs (epoch ms). - * Returns counts per outcome and totals for tokens + piece edits. - */ - aggregateReflectionMetrics( - userId: string, - sinceMs: number, - ): { + aggregateReflectionMetrics(userId: string, sinceMs: number): { applied: number; partial: number; abstained: number; @@ -4929,50 +813,11 @@ export class Repository { pieceEdits: number; totalRuns: number; } { - const rows = this.db - .prepare( - `SELECT outcome, COUNT(*) AS cnt, - SUM(tokens_in) AS ti, SUM(tokens_out) AS to_, - SUM(piece_edited) AS pe - FROM reflection_metrics - WHERE user_id = ? AND created_at >= ? - GROUP BY outcome`, - ) - .all(userId, sinceMs) as Array<{ - outcome: string; - cnt: number; - ti: number; - to_: number; - pe: number; - }>; - - const result = { - applied: 0, partial: 0, abstained: 0, rejected: 0, failed: 0, - tokensIn: 0, tokensOut: 0, pieceEdits: 0, totalRuns: 0, - }; - for (const r of rows) { - const o = r.outcome as keyof Pick; - if (o in result) (result as Record)[o] = r.cnt; - result.tokensIn += r.ti ?? 0; - result.tokensOut += r.to_ ?? 0; - result.pieceEdits += r.pe ?? 0; - result.totalRuns += r.cnt; - } - return result; + return reflectionRepo.aggregateReflectionMetrics(this.db, userId, sinceMs); } - // ── スペース・カレンダー(予定)───────────────────────────────────── - // - // CRUD はスペース id でスコープ。可視性判定は呼び出し側(space-api が - // getSpace({viewer}) で先にゲートする)に委ねる。日付集計はローカル TZ - // オフセット(分)を SQLite の date(...,'±N minutes') 修飾子で適用する - // (Usage ダッシュボードの localDayOf と同じ「UTC instant をオフセット分 - // ずらして暦日に落とす」方式の SQL 版)。 - - /** ローカル TZ オフセット(分)を SQLite の date() 修飾子文字列に変換する。 */ private tzModifier(tzOffsetMin: number): string { - const n = Math.trunc(tzOffsetMin); - return `${n >= 0 ? '+' : '-'}${Math.abs(n)} minutes`; + return calendarRepo.tzModifier(tzOffsetMin); } async createCalendarEvent(params: { @@ -4987,40 +832,9 @@ export class Repository { createdBy?: 'user' | 'agent'; sourceTaskId?: number | null; }): Promise { - // 終了日が開始日と同じ(または無効)なら単日として NULL に正規化する。 - const endDate = params.endDate && params.endDate > params.date ? params.endDate : null; - const time = params.time ?? null; - // 終了時刻は開始時刻があるときだけ意味を持つ。開始が終日なら end_time は捨てる。 - const endTime = time ? (params.endTime ?? null) : null; - const result = this.db - .prepare( - `INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - params.spaceId, - params.ownerId ?? null, - params.date, - endDate, - time, - endTime, - params.title, - params.description ?? null, - params.createdBy ?? 'user', - params.sourceTaskId ?? null, - ); - const row = this.db - .prepare(`SELECT * FROM calendar_events WHERE id = ?`) - .get(Number(result.lastInsertRowid)) as CalendarEventRow; - return rowToCalendarEvent(row); + return calendarRepo.createCalendarEvent(this.db, params); } - /** - * Record a tool request — either an agent's RequestTool declaration or a - * passively-captured blocked tool call. De-dups within the same job: an - * identical still-pending (job_id, movement_name, tool_name) row is reused - * so a retry loop does not spam the table. Returns the row id. - */ recordToolRequest(params: { id?: string; taskId?: string | null; @@ -5033,409 +847,100 @@ export class Repository { category?: ToolRequestCategory; status?: ToolRequestStatus; }): string { - const category: ToolRequestCategory = params.category ?? 'requested'; - const status: ToolRequestStatus = params.status ?? 'pending'; - // De-dup pending rows so a retry loop never grows the table without bound - // (the table has no GC). Scope by the most specific identity available — - // job → task → piece — so de-dup still works for runs without a job - // binding. scopeCol is a fixed identifier (not user input) → no injection. - const scopeCol: 'job_id' | 'task_id' | 'piece_name' = params.jobId - ? 'job_id' - : params.taskId - ? 'task_id' - : 'piece_name'; - const scopeVal = params.jobId ?? params.taskId ?? params.pieceName; - const existing = this.db - .prepare( - `SELECT id FROM tool_requests - WHERE ${scopeCol} = ? AND movement_name = ? AND tool_name = ? AND status = 'pending' - LIMIT 1`, - ) - .get(scopeVal, params.movementName, params.toolName) as { id: string } | undefined; - if (existing) return existing.id; - const id = params.id ?? randomUUID(); - this.db - .prepare( - `INSERT INTO tool_requests - (id, task_id, job_id, space_id, piece_name, movement_name, tool_name, reason, category, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - id, - params.taskId ?? null, - params.jobId ?? null, - params.spaceId ?? null, - params.pieceName, - params.movementName, - params.toolName, - params.reason ?? null, - category, - status, - ); - return id; + return toolRequestsRepo.recordToolRequest(this.db, params); } - /** All tool requests for a task, newest first. */ listToolRequestsByTask(taskId: string): ToolRequest[] { - const rows = this.db - .prepare(`SELECT * FROM tool_requests WHERE task_id = ? ORDER BY created_at DESC, id DESC`) - .all(taskId) as ToolRequestRow[]; - return rows.map(rowToToolRequest); + return toolRequestsRepo.listToolRequestsByTask(this.db, taskId); } - /** Per-piece aggregation: count + last-seen + a sample reason per - * (tool_name, category). `sampleReason` is one representative reason (not - * necessarily the most recent — it is a MAX over the group). */ aggregateToolRequestsByPiece(pieceName: string): ToolRequestAggregate[] { - const rows = this.db - .prepare( - `SELECT tool_name, category, COUNT(*) AS count, - MAX(created_at) AS last_seen, MAX(reason) AS sample_reason - FROM tool_requests - WHERE piece_name = ? - GROUP BY tool_name, category - ORDER BY count DESC, tool_name ASC`, - ) - .all(pieceName) as Array<{ - tool_name: string; category: string; count: number; last_seen: string; sample_reason: string | null; - }>; - return rows.map((r) => ({ - toolName: r.tool_name, - category: (r.category as ToolRequestCategory) ?? 'requested', - count: r.count, - lastSeen: r.last_seen, - sampleReason: r.sample_reason ?? null, - })); + return toolRequestsRepo.aggregateToolRequestsByPiece(this.db, pieceName); } - /** Fetch a single tool request by id. */ getToolRequest(id: string): ToolRequest | null { - const row = this.db.prepare(`SELECT * FROM tool_requests WHERE id = ?`).get(id) as ToolRequestRow | undefined; - return row ? rowToToolRequest(row) : null; + return toolRequestsRepo.getToolRequest(this.db, id); } - /** - * Record an approve/deny/auto-deny decision on a still-pending tool request. - * Returns false if the row is missing or already decided (idempotent / race - * safe — a concurrent decide won't double-apply). - */ - decideToolRequest( - id: string, - decision: { status: Exclude; grantScope?: 'task' | 'piece' | null; decidedBy?: string | null }, - ): boolean { - const res = this.db - .prepare( - `UPDATE tool_requests - SET status = ?, grant_scope = ?, decided_by = ?, decided_at = datetime('now') - WHERE id = ? AND status = 'pending'`, - ) - .run(decision.status, decision.grantScope ?? null, decision.decidedBy ?? null, id); - return res.changes > 0; + decideToolRequest(id: string, decision: { status: Exclude; grantScope?: 'task' | 'piece' | null; decidedBy?: string | null }): boolean { + return toolRequestsRepo.decideToolRequest(this.db, id, decision); } - /** Per-task grant overlay (tool names approved inline for this task). */ getGrantedTools(taskId: string): string[] { - const row = this.db.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as - | { granted_tools: string | null } - | undefined; - if (!row?.granted_tools) return []; - try { - const parsed = JSON.parse(row.granted_tools); - return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; - } catch { - return []; - } + return toolRequestsRepo.getGrantedTools(this.db, taskId); } - /** Add a tool to a task's grant overlay (idempotent — de-duped). Returns the new set. */ addGrantedTool(taskId: string, toolName: string): string[] { - const current = this.getGrantedTools(taskId); - if (current.includes(toolName)) return current; - const next = [...current, toolName]; - this.db.prepare(`UPDATE local_tasks SET granted_tools = ? WHERE id = ?`).run(JSON.stringify(next), taskId); - return next; + return toolRequestsRepo.addGrantedTool(this.db, taskId, toolName); } - async listCalendarEvents( - spaceId: string, - range?: { from?: string; to?: string }, - ): Promise { - const conditions = ['space_id = ?']; - const args: unknown[] = [spaceId]; - // 複数日対応: イベント [date, COALESCE(end_date,date)] が [from, to] と - // 重なるものを返す(単日 end_date=NULL は date 自身)。 - if (range?.from) { - conditions.push('COALESCE(end_date, date) >= ?'); - args.push(range.from); - } - if (range?.to) { - conditions.push('date <= ?'); - args.push(range.to); - } - const rows = this.db - .prepare( - `SELECT * FROM calendar_events WHERE ${conditions.join(' AND ')} - ORDER BY date ASC, (time IS NULL) DESC, time ASC, id ASC` - ) - .all(...args) as CalendarEventRow[]; - return rows.map(rowToCalendarEvent); + recordPackageRequest(params: { + id?: string; + taskId?: string | null; + jobId?: string | null; + spaceId?: string | null; + pieceName?: string | null; + movementName?: string | null; + spec: string; + normalizedName: string; + reason?: string | null; + status?: PackageRequestStatus; + }): string { + return toolRequestsRepo.recordPackageRequest(this.db, params); + } + + listPackageRequestsByTask(taskId: string): PackageRequest[] { + return toolRequestsRepo.listPackageRequestsByTask(this.db, taskId); + } + + listDeclinedPackageNamesByJob(jobId: string): string[] { + return toolRequestsRepo.listDeclinedPackageNamesByJob(this.db, jobId); + } + + getPackageRequest(id: string): PackageRequest | null { + return toolRequestsRepo.getPackageRequest(this.db, id); + } + + decidePackageRequest(id: string, decision: { status: Exclude; decidedBy?: string | null }): boolean { + return toolRequestsRepo.decidePackageRequest(this.db, id, decision); + } + + revertPackageRequestToPending(id: string): boolean { + return toolRequestsRepo.revertPackageRequestToPending(this.db, id); + } + + async listCalendarEvents(spaceId: string, range?: { from?: string; to?: string }): Promise { + return calendarRepo.listCalendarEvents(this.db, spaceId, range); } async getCalendarEvent(eventId: number): Promise { - const row = this.db - .prepare(`SELECT * FROM calendar_events WHERE id = ?`) - .get(eventId) as CalendarEventRow | undefined; - return row ? rowToCalendarEvent(row) : null; + return calendarRepo.getCalendarEvent(this.db, eventId); } - async updateCalendarEvent( - eventId: number, - fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }, - ): Promise { - const sets: string[] = []; - const args: unknown[] = []; - if (fields.date !== undefined) { - sets.push('date = ?'); - args.push(fields.date); - } - if (fields.endDate !== undefined) { - sets.push('end_date = ?'); - args.push(fields.endDate); - } - // time / end_time は連動。開始を終日(null)にしたら end_time も必ず null に落とす。 - if (fields.time !== undefined || fields.endTime !== undefined) { - const existing = await this.getCalendarEvent(eventId); - const newTime = fields.time !== undefined ? fields.time : (existing?.time ?? null); - let newEndTime = fields.endTime !== undefined ? fields.endTime : (existing?.endTime ?? null); - if (newTime == null) newEndTime = null; - if (fields.time !== undefined) { - sets.push('time = ?'); - args.push(newTime); - } - if (newEndTime !== (existing?.endTime ?? null)) { - sets.push('end_time = ?'); - args.push(newEndTime); - } - } - if (fields.title !== undefined) { - sets.push('title = ?'); - args.push(fields.title); - } - if (fields.description !== undefined) { - sets.push('description = ?'); - args.push(fields.description); - } - if (sets.length === 0) return this.getCalendarEvent(eventId); - sets.push(`updated_at = datetime('now')`); - args.push(eventId); - this.db.prepare(`UPDATE calendar_events SET ${sets.join(', ')} WHERE id = ?`).run(...args); - return this.getCalendarEvent(eventId); + async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise { + return calendarRepo.updateCalendarEvent(this.db, eventId, fields); } async deleteCalendarEvent(eventId: number): Promise { - this.db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId); + return calendarRepo.deleteCalendarEvent(this.db, eventId); } - /** - * 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。 - * タスクは local_tasks を space_id で絞り、created_at(UTC)をローカル TZ で - * 暦日にバケツ化した日付が [monthStart, monthEnd] に入るものを数える。 - * イベントは date 列がそのまま暦日(ローカル)なので素直に範囲で絞る。 - * ファイル数は重いので含めない(日詳細でのみ算出)。 - */ - /** - * SQL scope for "tasks belonging to this space's calendar". Normally just - * `space_id = ?`. A PERSONAL space also owns its owner's space-less tasks: - * personal-workspace tasks are stored with `space_id = NULL` (by design), so - * include `(space_id IS NULL AND owner_id = ?)` when a personalOwnerId is - * given. Mirrors worker.ts resolveToolSpaceId (NULL+owner → personal space). - */ - private calendarTaskScope( - spaceId: string, - personalOwnerId?: string | null, - alias = '', - ): { clause: string; params: unknown[] } { - const col = alias ? `${alias}.` : ''; - if (personalOwnerId) { - return { - clause: `(${col}space_id = ? OR (${col}space_id IS NULL AND ${col}owner_id = ?))`, - params: [spaceId, personalOwnerId], - }; - } - return { clause: `${col}space_id = ?`, params: [spaceId] }; + private calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } { + return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias); } - async getSpaceCalendarMonth( - spaceId: string, - opts: { monthStart: string; monthEnd: string; tzOffsetMin: number; personalOwnerId?: string | null }, - ): Promise<{ days: Record; events: CalendarEvent[] }> { - const mod = this.tzModifier(opts.tzOffsetMin); - const days: Record = {}; - const ensure = (d: string): CalendarDayCounts => { - if (!days[d]) days[d] = { taskCount: 0, eventCount: 0 }; - return days[d]!; - }; - - const scope = this.calendarTaskScope(spaceId, opts.personalOwnerId); - const taskRows = this.db - .prepare( - `SELECT date(created_at, ?) AS local_day, COUNT(*) AS c - FROM local_tasks - WHERE ${scope.clause} AND date(created_at, ?) BETWEEN ? AND ? - GROUP BY local_day` - ) - .all(mod, ...scope.params, mod, opts.monthStart, opts.monthEnd) as Array<{ local_day: string; c: number }>; - for (const r of taskRows) ensure(r.local_day).taskCount = r.c; - - const events = await this.listCalendarEvents(spaceId, { from: opts.monthStart, to: opts.monthEnd }); - // 複数日イベントは、またがる各日(当月内にクリップ)に 1 件ずつ計上する。 - for (const ev of events) { - const start = ev.date < opts.monthStart ? opts.monthStart : ev.date; - const end = (ev.endDate ?? ev.date) > opts.monthEnd ? opts.monthEnd : (ev.endDate ?? ev.date); - for (const d of enumerateLocalDays(start, end)) ensure(d).eventCount += 1; - } - return { days, events }; + async getSpaceCalendarMonth(spaceId: string, opts: { monthStart: string; monthEnd: string; tzOffsetMin: number; personalOwnerId?: string | null }): Promise<{ days: Record; events: CalendarEvent[] }> { + return calendarRepo.getSpaceCalendarMonth(this.db, spaceId, opts); } - /** - * 日詳細。tasks = その日(ローカル暦日)に作成された local_tasks(最新 job - * 状態付き)、events = その date の calendar_events。ファイル列挙は API 層 - * (fs スキャン)で行う。 - */ - async getSpaceCalendarDay( - spaceId: string, - date: string, - tzOffsetMin: number, - personalOwnerId?: string | null, - ): Promise<{ tasks: CalendarDayTask[]; events: CalendarEvent[] }> { - const mod = this.tzModifier(tzOffsetMin); - const scope = this.calendarTaskScope(spaceId, personalOwnerId, 'lt'); - const taskRows = this.db - .prepare( - `SELECT lt.id AS id, lt.title AS title, lt.state AS state, lt.created_at AS created_at, - (SELECT j.status FROM jobs j - WHERE j.repo = 'local/task-' || lt.id - ORDER BY j.created_at DESC LIMIT 1) AS status - FROM local_tasks lt - WHERE ${scope.clause} AND date(lt.created_at, ?) = ? - ORDER BY lt.created_at ASC, lt.id ASC` - ) - .all(...scope.params, mod, date) as Array<{ - id: number; title: string; state: string; created_at: string; status: string | null; - }>; - const tasks: CalendarDayTask[] = taskRows.map((r) => ({ - id: r.id, - title: r.title, - state: r.state, - status: r.status ?? null, - createdAt: r.created_at, - })); - const events = await this.listCalendarEvents(spaceId, { from: date, to: date }); - return { tasks, events }; + async getSpaceCalendarDay(spaceId: string, date: string, tzOffsetMin: number, personalOwnerId?: string | null): Promise<{ tasks: CalendarDayTask[]; events: CalendarEvent[] }> { + return calendarRepo.getSpaceCalendarDay(this.db, spaceId, date, tzOffsetMin, personalOwnerId); } - /** - * 横断(全スペース)月ビュー。viewer が見られるスペースだけを対象に、 - * 日 × スペースの {taskCount, eventCount} を集計する。可視性は spaces - * 一覧と同じ `buildVisibilityWhere`(listSpaces 経由)で絞るので、 - * 見えないスペースのカウントもイベントも一切返さない。 - * - * クエリ本数を抑えるため、対象 space_id を IN (...) でまとめて 2 本に集約する: - * 1) local_tasks を space_id + ローカル暦日で GROUP BY - * 2) calendar_events を space_id + date で GROUP BY - * さらに当月のイベント本体を 1 本でまとめて取得する(各 CalendarEvent は - * spaceId を持つので UI 側でスペース別に振り分けられる)。 - */ - async getCrossSpaceCalendarMonth( - viewer: Express.User, - opts: { monthStart: string; monthEnd: string; tzOffsetMin: number }, - ): Promise { - // 可視スペースの列挙は spaces 一覧とまったく同じ可視性判定を流用する。 - const visibleSpaces = await this.listSpaces({ viewer }); - const spaces: CrossCalendarSpace[] = visibleSpaces.map((s) => ({ - id: s.id, - name: s.title, - color: s.brandColor ?? spaceColor(s.id), - })); - - const days: Record> = {}; - if (spaces.length === 0) { - return { days, events: [], spaces }; - } - - const ids = spaces.map((s) => s.id); - const placeholders = ids.map(() => '?').join(','); - const mod = this.tzModifier(opts.tzOffsetMin); - const ensure = (date: string, spaceId: string): CalendarDayCounts => { - const byDay = (days[date] ??= {}); - return (byDay[spaceId] ??= { taskCount: 0, eventCount: 0 }); - }; - - const taskRows = this.db - .prepare( - `SELECT space_id AS sid, date(created_at, ?) AS local_day, COUNT(*) AS c - FROM local_tasks - WHERE space_id IN (${placeholders}) AND date(created_at, ?) BETWEEN ? AND ? - GROUP BY sid, local_day` - ) - .all(mod, ...ids, mod, opts.monthStart, opts.monthEnd) as Array<{ - sid: string; local_day: string; c: number; - }>; - for (const r of taskRows) ensure(r.local_day, r.sid).taskCount = r.c; - - // 個人スペースのタスクは space_id が NULL(owner_id で紐付く)ため、上の - // `space_id IN (...)` では拾えず全体カレンダーに反映されなかった。per-space カレンダーの - // calendarTaskScope と同じ owner フォールバックで、`space_id IS NULL AND owner_id=個人スペース - // 所有者` のタスクを個人スペース列に計上する。 - for (const ps of visibleSpaces) { - if (ps.kind !== 'personal' || !ps.ownerId) continue; - const personalRows = this.db - .prepare( - `SELECT date(created_at, ?) AS local_day, COUNT(*) AS c - FROM local_tasks - WHERE space_id IS NULL AND owner_id = ? AND date(created_at, ?) BETWEEN ? AND ? - GROUP BY local_day` - ) - .all(mod, ps.ownerId, mod, opts.monthStart, opts.monthEnd) as Array<{ local_day: string; c: number }>; - for (const r of personalRows) ensure(r.local_day, ps.id).taskCount += r.c; - } - - // 複数日対応: 当月に重なるイベントを 1 本でまとめて取得し、各日(当月内に - // クリップ)に 1 件ずつ計上する。終了日が当月外の単日 end_date=NULL は date 自身。 - const eventRows = this.db - .prepare( - `SELECT * FROM calendar_events - WHERE space_id IN (${placeholders}) - AND COALESCE(end_date, date) >= ? AND date <= ? - ORDER BY date ASC, (time IS NULL) DESC, time ASC, id ASC` - ) - .all(...ids, opts.monthStart, opts.monthEnd) as CalendarEventRow[]; - const events = eventRows.map(rowToCalendarEvent); - for (const ev of events) { - const start = ev.date < opts.monthStart ? opts.monthStart : ev.date; - const end = (ev.endDate ?? ev.date) > opts.monthEnd ? opts.monthEnd : (ev.endDate ?? ev.date); - for (const d of enumerateLocalDays(start, end)) ensure(d, ev.spaceId).eventCount += 1; - } - - return { days, events, spaces }; + async getCrossSpaceCalendarMonth(viewer: Express.User, opts: { monthStart: string; monthEnd: string; tzOffsetMin: number }): Promise { + return calendarRepo.getCrossSpaceCalendarMonth(this.db, viewer, opts); } - // ── AAO Gateway Phase 2a: virtual keys ─────────────────────────────── - // - // The gateway auth middleware reads `findGatewayVirtualKeyByHash` on - // every request, so it MUST stay an indexed point lookup. The partial - // index `idx_gateway_keys_hash_active` covers that path. Admin-side - // methods (list/get/revoke/rotate/delete) are not hot. - - /** - * Insert a new virtual key row. Throws on UNIQUE(key_hash) violation — - * callers must hash the raw key first (via src/gateway/key-format.ts) - * and pass the hash here. The raw key is never accepted by the - * Repository on purpose: there is no path that could log it. - * - * `allowedModels` is JSON-encoded when present and stored as NULL when - * omitted — distinct from `[]` which means "lock to zero models". - */ createGatewayVirtualKey(params: { id?: string; keyHash: string; @@ -5450,51 +955,10 @@ export class Repository { /** Phase 2b: optional requests-per-minute cap. null/undefined = unlimited. */ rateLimitRpm?: number | null; }): GatewayVirtualKey { - const id = params.id ?? randomUUID(); - const allowedJson = - params.allowedModels === null || params.allowedModels === undefined - ? null - : JSON.stringify(params.allowedModels); - const source: GatewayVirtualKeySource = params.source ?? 'admin'; - const createdAt = params.createdAt ?? new Date().toISOString(); - const tokensBudget = normalizeOptionalPositiveInt(params.tokensBudget); - const rateLimitRpm = normalizeOptionalPositiveInt(params.rateLimitRpm); - this.db - .prepare( - `INSERT INTO gateway_virtual_keys - (id, key_hash, key_prefix, team, allowed_models, source, created_at, created_by, tokens_budget, rate_limit_rpm) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - id, - params.keyHash, - params.keyPrefix, - params.team, - allowedJson, - source, - createdAt, - params.createdBy ?? null, - tokensBudget, - rateLimitRpm, - ); - const row = this.db - .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) - .get(id) as GatewayVirtualKeyRow; - return rowToGatewayVirtualKey(row); + return gatewayRepo.createGatewayVirtualKey(this.db, params); } - /** - * Phase 2b: partial update of a virtual key's policy fields. The - * bearer hash, team, source, and creation metadata are immutable here - * (use rotate to change the bearer). Each field is opt-in — undefined - * means "leave alone"; explicit null clears the limit (= unlimited). - * - * Returns the refreshed row. Throws when the id doesn't exist (caller - * is expected to 404 before calling). - */ - updateGatewayVirtualKey( - id: string, - patch: { + updateGatewayVirtualKey(id: string, patch: { /** * Phase 3a follow-up: team is now patchable so the config-migration * importer can propagate a YAML-side team rename to the DB. Admin @@ -5506,198 +970,38 @@ export class Repository { tokensBudget?: number | null; rateLimitRpm?: number | null; allowedModels?: string[] | null; - }, - ): GatewayVirtualKey { - const sets: string[] = []; - const args: unknown[] = []; - if (Object.prototype.hasOwnProperty.call(patch, 'team')) { - sets.push('team = ?'); - args.push(patch.team); - } - if (Object.prototype.hasOwnProperty.call(patch, 'tokensBudget')) { - sets.push('tokens_budget = ?'); - args.push(normalizeOptionalPositiveInt(patch.tokensBudget)); - } - if (Object.prototype.hasOwnProperty.call(patch, 'rateLimitRpm')) { - sets.push('rate_limit_rpm = ?'); - args.push(normalizeOptionalPositiveInt(patch.rateLimitRpm)); - } - if (Object.prototype.hasOwnProperty.call(patch, 'allowedModels')) { - sets.push('allowed_models = ?'); - args.push( - patch.allowedModels === null || patch.allowedModels === undefined - ? null - : JSON.stringify(patch.allowedModels), - ); - } - if (sets.length > 0) { - args.push(id); - this.db - .prepare(`UPDATE gateway_virtual_keys SET ${sets.join(', ')} WHERE id = ?`) - .run(...args); - } - const refreshed = this.findGatewayVirtualKeyById(id); - if (!refreshed) { - throw new Error(`updateGatewayVirtualKey: id not found (${id})`); - } - return refreshed; + }): GatewayVirtualKey { + return gatewayRepo.updateGatewayVirtualKey(this.db, id, patch); } - /** - * Auth hot path: look up an active (non-revoked) key by SHA-256 hash. - * The partial index covers this query so the planner uses it directly. - * Returns null on miss; never throws. - */ findGatewayVirtualKeyByHash(keyHash: string): GatewayVirtualKey | null { - const row = this.db - .prepare( - `SELECT * FROM gateway_virtual_keys WHERE key_hash = ? AND revoked_at IS NULL`, - ) - .get(keyHash) as GatewayVirtualKeyRow | undefined; - return row ? rowToGatewayVirtualKey(row) : null; + return gatewayRepo.findGatewayVirtualKeyByHash(this.db, keyHash); } - /** - * Admin lookup by row id. Includes revoked keys (the admin list/detail - * view shows them so an admin can audit a recent revoke). - */ findGatewayVirtualKeyById(id: string): GatewayVirtualKey | null { - const row = this.db - .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`) - .get(id) as GatewayVirtualKeyRow | undefined; - return row ? rowToGatewayVirtualKey(row) : null; + return gatewayRepo.findGatewayVirtualKeyById(this.db, id); } - /** - * Admin list. `activeOnly` filters out revoked rows; `team` narrows by - * team string (exact match). Ordering is `created_at DESC, id DESC` so - * the freshest issuance is first regardless of system clock skew. - */ listGatewayVirtualKeys(opts?: { team?: string; activeOnly?: boolean }): GatewayVirtualKey[] { - const where: string[] = []; - const args: unknown[] = []; - if (opts?.team !== undefined) { - where.push('team = ?'); - args.push(opts.team); - } - if (opts?.activeOnly) { - where.push('revoked_at IS NULL'); - } - const sql = - `SELECT * FROM gateway_virtual_keys` + - (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') + - ` ORDER BY created_at DESC, id DESC`; - const rows = this.db.prepare(sql).all(...args) as GatewayVirtualKeyRow[]; - return rows.map(rowToGatewayVirtualKey); + return gatewayRepo.listGatewayVirtualKeys(this.db, opts); } - /** - * Mark a key as revoked. Returns true if the row was active (so the - * caller can return a clean 200) and false if it was already revoked - * or doesn't exist (so the caller can return 404 / 409). Idempotent - * second calls return false. - */ revokeGatewayVirtualKey(id: string, revokedBy: string, at?: string): boolean { - const ts = at ?? new Date().toISOString(); - const info = this.db - .prepare( - `UPDATE gateway_virtual_keys - SET revoked_at = ?, revoked_by = ? - WHERE id = ? AND revoked_at IS NULL`, - ) - .run(ts, revokedBy, id); - return info.changes > 0; + return gatewayRepo.revokeGatewayVirtualKey(this.db, id, revokedBy, at); } - /** - * Hard delete. The admin API guards `source='config-import'` and - * returns 400 before calling this — but the Repository itself doesn't - * enforce that (tests need to be able to clean up). Returns true if a - * row was deleted. - */ - /** - * Hard-delete a virtual key row. - * - * Defense-in-depth: refuses to delete rows with `source='config-import'` - * by throwing. The admin REST API also rejects this case (returning a - * 400 with a human-readable message), but a future internal caller - * could easily forget — and a hard delete of a config-import row - * would simply be replayed on the next gateway boot when - * importConfigKeysToDb re-imports the entry from config.yaml. That - * recreates the row with a different id, which silently breaks any - * audit history that referenced the previous id and is generally - * confusing operator behavior. Force callers to use `revoke` (soft - * delete) or to remove the entry from config.yaml first. - * - * Returns true when a row was deleted, false when the id didn't - * exist. Throws when the row exists but is config-import. - */ deleteGatewayVirtualKey(id: string): boolean { - const row = this.findGatewayVirtualKeyById(id); - if (!row) return false; - if (row.source === 'config-import') { - throw new Error( - `cannot delete config-import virtual key (id=${id}); ` + - "remove the entry from config.yaml's gateway.virtual_keys instead, " + - 'or use revoke for a soft delete', - ); - } - const info = this.db - .prepare(`DELETE FROM gateway_virtual_keys WHERE id = ?`) - .run(id); - return info.changes > 0; + return gatewayRepo.deleteGatewayVirtualKey(this.db, id); } - /** - * Bump `last_used_at` for an active key. Called from the gateway auth - * middleware on successful match. Per-request volume can be high, so - * callers typically dedup with a 30-second in-memory bucket (see - * src/gateway/auth.ts) before touching the DB. Best-effort: failures - * are swallowed by the caller so a temporary write-lock contention - * never blocks auth. - */ touchGatewayVirtualKeyLastUsed(id: string, at?: string): void { - const ts = at ?? new Date().toISOString(); - this.db - .prepare(`UPDATE gateway_virtual_keys SET last_used_at = ? WHERE id = ?`) - .run(ts, id); + return gatewayRepo.touchGatewayVirtualKeyLastUsed(this.db, id, at); } - // ── AAO Gateway Phase 2b: usage tracking ───────────────────────────── - // - // Read path is on the budget enforcement hot loop, so it stays a - // single point lookup over the composite PRIMARY KEY. Write path is - // an UPSERT (`ON CONFLICT … DO UPDATE`) so the gateway can fire-and- - // forget after every chat completion without a pre-read. - - /** - * Point-lookup over the (key_id, period_start) PRIMARY KEY. Returns - * null when there's no row yet (= "no usage in this period"), which - * the caller treats as zero counters. - */ getGatewayKeyUsage(keyId: string, periodStart: string): GatewayKeyUsage | null { - const row = this.db - .prepare( - `SELECT * FROM gateway_key_usage WHERE key_id = ? AND period_start = ?`, - ) - .get(keyId, periodStart) as GatewayKeyUsageRow | undefined; - return row ? rowToGatewayKeyUsage(row) : null; + return gatewayRepo.getGatewayKeyUsage(this.db, keyId, periodStart); } - /** - * UPSERT: bump the per-(key, period) counters by the supplied deltas. - * All three deltas are clamped at zero so a buggy caller can never - * decrement a counter. `last_updated_at` always reflects the call - * time (or the explicit `at` override) so a downstream sweeper can - * tell when activity stopped. - * - * Called from two places on the gateway hot path: - * 1. stream-proxy's finally block (token deltas from upstream usage) - * 2. rate-limiter's 30-second batch flush (request count only) - * - * The second caller passes `tokensIn=0 tokensOut=0` so the UPSERT - * still creates a row even when no token usage was extracted. - */ incrementGatewayKeyUsage(params: { keyId: string; period: string; @@ -5706,217 +1010,35 @@ export class Repository { requests?: number; at?: string; }): void { - const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); - const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); - const reqs = Math.max(0, Math.floor(params.requests ?? 0)); - const ts = params.at ?? new Date().toISOString(); - this.db - .prepare( - `INSERT INTO gateway_key_usage - (key_id, period_start, tokens_in, tokens_out, requests, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (key_id, period_start) DO UPDATE SET - tokens_in = tokens_in + excluded.tokens_in, - tokens_out = tokens_out + excluded.tokens_out, - requests = requests + excluded.requests, - last_updated_at = excluded.last_updated_at`, - ) - .run(params.keyId, params.period, tIn, tOut, reqs, ts); + return gatewayRepo.incrementGatewayKeyUsage(this.db, params); } - /** - * Admin view: history of usage rows for a key, freshest period first. - * Default limit 12 covers a full year of monthly buckets — enough for - * the in-UI bar chart and for `GET /:id/usage` to embed history without - * a follow-up call. - */ listGatewayKeyUsagesByKey(keyId: string, opts?: { limit?: number }): GatewayKeyUsage[] { - const limit = Math.max(1, Math.min(120, Math.floor(opts?.limit ?? 12))); - const rows = this.db - .prepare( - `SELECT * FROM gateway_key_usage - WHERE key_id = ? - ORDER BY period_start DESC - LIMIT ?`, - ) - .all(keyId, limit) as GatewayKeyUsageRow[]; - return rows.map(rowToGatewayKeyUsage); + return gatewayRepo.listGatewayKeyUsagesByKey(this.db, keyId, opts); } - // ── Per-user daily LLM usage (gateway + direct) ────────────────────── - // - // Recorded at the OpenAICompatClient completion boundary for every - // successful chat completion. UPSERT on the (day, user_id, source, - // model, route) grain. Separate lens from gateway_key_usage — never - // summed across the two tables. Spec: - // docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md - - /** - * UPSERT: bump per-(day, user, source, model, route) counters. Deltas - * are clamped at zero. `day` defaults to the UTC day of `at` (or now). - * Called once per successful stream completion; `usage`-less completions - * still bump `requests` (tokens 0) so a 0-token request is distinct from - * a failed/aborted one (which is never recorded). - */ incrementLlmUsage(params: LlmUsageIncrement): void { - const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); - const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); - const reqs = Math.max(0, Math.floor(params.requests ?? 1)); - const ts = params.at ?? new Date().toISOString(); - const day = params.day ?? ts.slice(0, 10); - this.db - .prepare( - `INSERT INTO llm_usage_daily - (day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (day, user_id, source, model, route) DO UPDATE SET - tokens_in = tokens_in + excluded.tokens_in, - tokens_out = tokens_out + excluded.tokens_out, - requests = requests + excluded.requests, - last_updated_at = excluded.last_updated_at`, - ) - .run(day, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); + return llmUsageRepo.incrementLlmUsage(this.db, params); } - /** - * Daily time series for the usage dashboard, grouped by (day, user_id, - * source) with model/route collapsed. `userId` filter scopes a non-admin - * to their own rows; omit it for the admin all-users view (callers can - * collapse user_id afterwards). Inclusive `from`/`to` are 'YYYY-MM-DD'. - */ queryLlmUsageDaily(opts: { from: string; to: string; userId?: string }): LlmUsageDailyAgg[] { - const where = ['day >= ?', 'day <= ?']; - const args: unknown[] = [opts.from, opts.to]; - if (opts.userId !== undefined) { - where.push('user_id = ?'); - args.push(opts.userId); - } - const rows = this.db - .prepare( - `SELECT day, user_id, source, - SUM(tokens_in) AS tokens_in, - SUM(tokens_out) AS tokens_out, - SUM(requests) AS requests - FROM llm_usage_daily - WHERE ${where.join(' AND ')} - GROUP BY day, user_id, source - ORDER BY day ASC`, - ) - .all(...args) as Array<{ - day: string; - user_id: string; - source: string; - tokens_in: number; - tokens_out: number; - requests: number; - }>; - return rows.map((r) => ({ - day: r.day, - userId: r.user_id, - source: r.source, - tokensIn: r.tokens_in, - tokensOut: r.tokens_out, - requests: r.requests, - })); + return llmUsageRepo.queryLlmUsageDaily(this.db, opts); } - /** - * v2 write path: UPSERT per-(hour, user, source, model, route) counters. - * `hour` defaults to the current UTC hour 'YYYY-MM-DDTHH'. Same contract as - * incrementLlmUsage (deltas clamped at zero; usage-less completions still - * bump `requests`). Supersedes incrementLlmUsage as the recorder target. - */ incrementLlmUsageHourly(params: LlmUsageHourlyIncrement): void { - const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0)); - const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0)); - const reqs = Math.max(0, Math.floor(params.requests ?? 1)); - const ts = params.at ?? new Date().toISOString(); - const hour = params.hour ?? ts.slice(0, 13); - this.db - .prepare( - `INSERT INTO llm_usage_hourly - (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (hour, user_id, source, model, route) DO UPDATE SET - tokens_in = tokens_in + excluded.tokens_in, - tokens_out = tokens_out + excluded.tokens_out, - requests = requests + excluded.requests, - last_updated_at = excluded.last_updated_at`, - ) - .run(hour, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts); + return llmUsageRepo.incrementLlmUsageHourly(this.db, params); } - /** - * Raw hour-grain rows for the usage dashboard v2, no axis collapsed so the - * API can group by any of source/model/route/user/org and re-bucket into the - * viewer's local timezone. `userId` scopes a non-admin to their own rows. - * Inclusive `fromHour`/`toHour` are 'YYYY-MM-DDTHH' (UTC) — callers widen the - * UTC window by ±1 day before filtering precisely against local days. - */ queryLlmUsageHourly(opts: { fromHour: string; toHour: string; userId?: string; }): LlmUsageHourlyRow[] { - const where = ['hour >= ?', 'hour <= ?']; - const args: unknown[] = [opts.fromHour, opts.toHour]; - if (opts.userId !== undefined) { - where.push('user_id = ?'); - args.push(opts.userId); - } - const rows = this.db - .prepare( - `SELECT hour, user_id, source, model, route, tokens_in, tokens_out, requests - FROM llm_usage_hourly - WHERE ${where.join(' AND ')} - ORDER BY hour ASC`, - ) - .all(...args) as Array<{ - hour: string; - user_id: string; - source: string; - model: string; - route: string; - tokens_in: number; - tokens_out: number; - requests: number; - }>; - return rows.map((r) => ({ - hour: r.hour, - userId: r.user_id, - source: r.source, - model: r.model, - route: r.route, - tokensIn: r.tokens_in, - tokensOut: r.tokens_out, - requests: r.requests, - })); + return llmUsageRepo.queryLlmUsageHourly(this.db, opts); } - /** - * Map every user to a single org label for the usage dashboard's "by org" - * breakdown. Unions Gitea org cache (user_gitea_orgs) and local orgs - * (local_org_members → local_orgs.name); a multi-org user collapses to the - * alphabetically-first org name (MIN) so the breakdown is deterministic. - * Users with no org (and the 'local'/'system' sentinels) are simply absent — - * the API buckets them under 'no-org'. - */ getUsageOrgMap(): Map { - const rows = this.db - .prepare( - `SELECT user_id, MIN(org_name) AS org_name FROM ( - SELECT user_id, org_name FROM user_gitea_orgs - UNION ALL - SELECT m.user_id AS user_id, o.name AS org_name - FROM local_org_members m - JOIN local_orgs o ON o.id = m.org_id - ) - GROUP BY user_id`, - ) - .all() as Array<{ user_id: string; org_name: string }>; - const map = new Map(); - for (const r of rows) map.set(r.user_id, r.org_name); - return map; + return llmUsageRepo.getUsageOrgMap(this.db); } /** Return the underlying Database instance (needed by migrate.ts and session store) */ @@ -5924,240 +1046,40 @@ export class Repository { return this.db; } - // ── Browser Notifications V2: push_subscriptions ──────────────────── - // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md - - /** - * Insert or, on endpoint collision, transfer ownership to the new user. - * endpoint is globally UNIQUE: the same browser logging in as a different - * user re-uses the same push service URL, so we move it rather than fail. - */ upsertPushSubscription(input: UpsertPushSubscriptionInput): { id: string } { - const existing = this.db - .prepare('SELECT id FROM push_subscriptions WHERE endpoint = ?') - .get(input.endpoint) as { id: string } | undefined; - if (existing) { - this.db - .prepare( - `UPDATE push_subscriptions - SET user_id = ?, p256dh = ?, auth = ?, user_agent = ?, - vapid_key_id = ?, last_success_at = NULL, - last_failure_at = NULL, failure_count = 0 - WHERE id = ?`, - ) - .run( - input.userId, - input.p256dh, - input.auth, - input.userAgent ?? null, - input.vapidKeyId, - existing.id, - ); - return { id: existing.id }; - } - const id = randomUUID(); - this.db - .prepare( - `INSERT INTO push_subscriptions - (id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - id, - input.userId, - input.endpoint, - input.p256dh, - input.auth, - input.userAgent ?? null, - input.vapidKeyId, - ); - return { id }; + return notificationsRepo.upsertPushSubscription(this.db, input); } listPushSubscriptionsForUser(userId: string): PushSubscriptionRecord[] { - const rows = this.db - .prepare( - `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, - created_at, last_success_at, last_failure_at, failure_count - FROM push_subscriptions - WHERE user_id = ? - ORDER BY created_at ASC`, - ) - .all(userId) as Array<{ - id: string; - user_id: string; - endpoint: string; - p256dh: string; - auth: string; - user_agent: string | null; - vapid_key_id: string; - created_at: string; - last_success_at: string | null; - last_failure_at: string | null; - failure_count: number; - }>; - return rows.map(rowToPushSubscription); + return notificationsRepo.listPushSubscriptionsForUser(this.db, userId); } getPushSubscriptionById(id: string): PushSubscriptionRecord | null { - const row = this.db - .prepare( - `SELECT id, user_id, endpoint, p256dh, auth, user_agent, vapid_key_id, - created_at, last_success_at, last_failure_at, failure_count - FROM push_subscriptions WHERE id = ?`, - ) - .get(id) as Parameters[0] | undefined; - return row ? rowToPushSubscription(row) : null; + return notificationsRepo.getPushSubscriptionById(this.db, id); } deletePushSubscription(id: string): void { - this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(id); + return notificationsRepo.deletePushSubscription(this.db, id); } markPushSubscriptionSuccess(id: string): void { - this.db - .prepare( - `UPDATE push_subscriptions - SET last_success_at = datetime('now'), failure_count = 0 - WHERE id = ?`, - ) - .run(id); + return notificationsRepo.markPushSubscriptionSuccess(this.db, id); } markPushSubscriptionFailure(id: string): void { - this.db - .prepare( - `UPDATE push_subscriptions - SET last_failure_at = datetime('now'), - failure_count = failure_count + 1 - WHERE id = ?`, - ) - .run(id); + return notificationsRepo.markPushSubscriptionFailure(this.db, id); } - // ── Browser Notifications V2: user_notification_prefs ──────────────── - - /** - * Read per-user prefs; create row with defaults if none exists. - * Defaults: enabled=true, all events on, include_details=false, v1_migrated=false. - */ getUserNotificationPrefs(userId: string): NotificationPrefs { - const row = this.db - .prepare( - `SELECT user_id, enabled, event_running, event_succeeded, - event_failed, event_waiting_human, include_details, - v1_migrated, updated_at - FROM user_notification_prefs WHERE user_id = ?`, - ) - .get(userId) as - | { - user_id: string; - enabled: number; - event_running: number; - event_succeeded: number; - event_failed: number; - event_waiting_human: number; - include_details: number; - v1_migrated: number; - updated_at: string; - } - | undefined; - if (!row) { - // Lazily create the default row so subsequent reads/updates are - // a simple UPDATE rather than a conditional insert. - this.db - .prepare( - `INSERT INTO user_notification_prefs (user_id) VALUES (?) - ON CONFLICT(user_id) DO NOTHING`, - ) - .run(userId); - return { - userId, - enabled: true, - events: { running: true, succeeded: true, failed: true, waiting_human: true }, - includeDetails: false, - v1Migrated: false, - updatedAt: new Date().toISOString(), - }; - } - return { - userId: row.user_id, - enabled: row.enabled !== 0, - events: { - running: row.event_running !== 0, - succeeded: row.event_succeeded !== 0, - failed: row.event_failed !== 0, - waiting_human: row.event_waiting_human !== 0, - }, - includeDetails: row.include_details !== 0, - v1Migrated: row.v1_migrated !== 0, - updatedAt: row.updated_at, - }; + return notificationsRepo.getUserNotificationPrefs(this.db, userId); } upsertUserNotificationPrefs(userId: string, update: NotificationPrefsUpdate): void { - // Ensure a row exists first (lazy default creation matches getUserNotificationPrefs). - this.db - .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) - .run(userId); - const sets: string[] = []; - const params: Array = []; - if (update.enabled !== undefined) { - sets.push('enabled = ?'); - params.push(update.enabled ? 1 : 0); - } - if (update.events) { - if (update.events.running !== undefined) { - sets.push('event_running = ?'); - params.push(update.events.running ? 1 : 0); - } - if (update.events.succeeded !== undefined) { - sets.push('event_succeeded = ?'); - params.push(update.events.succeeded ? 1 : 0); - } - if (update.events.failed !== undefined) { - sets.push('event_failed = ?'); - params.push(update.events.failed ? 1 : 0); - } - if (update.events.waiting_human !== undefined) { - sets.push('event_waiting_human = ?'); - params.push(update.events.waiting_human ? 1 : 0); - } - } - if (update.includeDetails !== undefined) { - sets.push('include_details = ?'); - params.push(update.includeDetails ? 1 : 0); - } - if (update.v1Migrated !== undefined) { - sets.push('v1_migrated = ?'); - params.push(update.v1Migrated ? 1 : 0); - } - if (sets.length === 0) return; - sets.push("updated_at = datetime('now')"); - params.push(userId); - this.db - .prepare(`UPDATE user_notification_prefs SET ${sets.join(', ')} WHERE user_id = ?`) - .run(...params); + return notificationsRepo.upsertUserNotificationPrefs(this.db, userId, update); } - /** - * One-time V1 (localStorage) → V2 (server) preferences migration marker. - * Returns true if this call performed the migration mark (caller should - * then apply the localStorage values via upsertUserNotificationPrefs). - * Returns false if already migrated (caller should treat as 409 conflict). - */ markV1MigrationComplete(userId: string): boolean { - this.db - .prepare(`INSERT OR IGNORE INTO user_notification_prefs (user_id) VALUES (?)`) - .run(userId); - const result = this.db - .prepare( - `UPDATE user_notification_prefs - SET v1_migrated = 1, updated_at = datetime('now') - WHERE user_id = ? AND v1_migrated = 0`, - ) - .run(userId); - return result.changes > 0; + return notificationsRepo.markV1MigrationComplete(this.db, userId); } close(): void { @@ -6165,33 +1087,5 @@ export class Repository { } } -function rowToPushSubscription(row: { - id: string; - user_id: string; - endpoint: string; - p256dh: string; - auth: string; - user_agent: string | null; - vapid_key_id: string; - created_at: string; - last_success_at: string | null; - last_failure_at: string | null; - failure_count: number; -}): PushSubscriptionRecord { - return { - id: row.id, - userId: row.user_id, - endpoint: row.endpoint, - p256dh: row.p256dh, - auth: row.auth, - userAgent: row.user_agent, - vapidKeyId: row.vapid_key_id, - createdAt: row.created_at, - lastSuccessAt: row.last_success_at, - lastFailureAt: row.last_failure_at, - failureCount: row.failure_count, - }; -} - export { BrowserSessionRepo } from './browser-session-repo.js'; export type { BrowserSessionProfile, CreateProfileInput, AuditInput } from './browser-session-repo.js'; diff --git a/src/db/schema.sql b/src/db/schema.sql index 9caf2cb..6d0bf09 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -215,6 +215,30 @@ CREATE INDEX IF NOT EXISTS idx_tool_requests_piece ON tool_requests (piece_name) -- de-dup lookup (job-scoped pending row) — avoids a full scan per record call CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status); +-- エージェント発 Python パッケージ要求(RequestPackage)。実行中に「このライブラリを +-- 使いたい」と申告 → task write 権限者が承認 → そのスペースの overlay に install → +-- ジョブ再開で import 可能に。承認時のインストールは PR2 の installSpacePackages +-- (bwrap 必須 fail-closed / wheels のみ / 固定 index / shadowing 拒否)を再利用する。 +-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合) +CREATE TABLE IF NOT EXISTS package_requests ( + id TEXT PRIMARY KEY, -- randomUUID + task_id TEXT, -- local_tasks 擬似 id(nullable) + job_id TEXT, -- jobs.id(nullable、承認後 resume 対象) + space_id TEXT, -- install 先スペース(承認に必須) + piece_name TEXT, -- 要求元 piece(診断用・nullable) + movement_name TEXT, -- 要求元 movement(UI 表示用・nullable) + spec TEXT NOT NULL, -- 正規化済み pip spec(例: requests==2.32.3) + normalized_name TEXT NOT NULL, -- PEP 503 正規化名(例: requests) + reason TEXT, -- なぜ必要か(エージェント申告) + status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | denied | auto_denied + decided_by TEXT, -- 承認/拒否した user_id + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_package_requests_task ON package_requests (task_id); +-- de-dup lookup (job-scoped pending row) — avoids a full scan per record call +CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status); + -- ワークスペースファイルの来歴(プロヴェナンス)台帳。永続ワークスペースが -- 複数タスク間で共有されるため、各ファイルを「どのタスクが作った / アップロード -- したか」「最後に変更したのは誰か」を追跡する。ファイル本文にはタグを埋めない。 diff --git a/src/engine/agent-loop.ts b/src/engine/agent-loop.ts index 6ccef40..16d3932 100644 --- a/src/engine/agent-loop.ts +++ b/src/engine/agent-loop.ts @@ -49,6 +49,7 @@ import { runLlmIteration } from './agent-loop/llm-iteration.js'; import { buildBrowserWaitResult, buildToolApprovalWaitResult, + buildPackageApprovalWaitResult, buildSubtaskWaitResult, buildCompleteAutoParkResult, } from './agent-loop/pending-states.js'; @@ -415,6 +416,8 @@ export async function executeMovement( } const toolApprovalWait = buildToolApprovalWaitResult(toolCtx, toolsUsed, movement.name); if (toolApprovalWait) return finishMovement(toolApprovalWait); + const packageApprovalWait = buildPackageApprovalWaitResult(toolCtx, toolsUsed, movement.name); + if (packageApprovalWait) return finishMovement(packageApprovalWait); const subtaskWait = buildSubtaskWaitResult(toolCtx, toolsUsed, movement.name); if (subtaskWait) return finishMovement(subtaskWait); regularToolsUsed += dispatch.regularToolsUsedDelta; diff --git a/src/engine/agent-loop/context-control.test.ts b/src/engine/agent-loop/context-control.test.ts index 2fb0899..b019d43 100644 --- a/src/engine/agent-loop/context-control.test.ts +++ b/src/engine/agent-loop/context-control.test.ts @@ -57,3 +57,108 @@ describe('buildContextOverflowResult — same terminal-default policy (Codex #2) expect(res.next).toBe('verify'); }); }); + +// Livelock fix: when the client blocks a request pre-send but the local guard +// re-check finds nothing it can shrink, returning "recovered" resends the +// byte-identical request, which the client blocks again — an infinite spin +// that burns maxIterations at ~10ms per iteration (observed in production: +// 195 consecutive 14ms error calls). A recovery that changed nothing is not +// a recovery; the movement must end via the context-overflow path instead. +describe('handleLLMError — client preflight block (livelock fix)', () => { + const blockedMessage = + 'LLM request blocked before send: estimated prompt size 102,415 tokens exceeds safe limit 102,400 tokens (80% of context 128,000). Narrow the requested content.'; + + it('ends the movement instead of spinning when the guard cannot shrink anything', async () => { + const { handleLLMError } = await import('./context-control.js'); + const res = await handleLLMError(blockedMessage, { + movement: mv(undefined), + messages: [{ role: 'user', content: 'tiny prompt' }], + tools: [], + toolsUsed: ['Grep'], + contextManager: new ContextManager({ limitTokens: 128_000 }), + promptGuardRatio: 0.8, + runIsolatedLlm: async () => 'summary', + }); + expect(res).not.toBeNull(); + expect(res?.next).toBe('ABORT'); + expect(res?.abortCode).toBe('context_overflow'); + }); + + it('still returns null (continue) when the guard actually freed space', async () => { + const { handleLLMError } = await import('./context-control.js'); + const bigContent = 'A'.repeat(21_600); // ~6,170 tokens per read + const readPair = (callId: string): import('../../llm/openai-compat.js').Message[] => ([ + { + role: 'assistant', + tool_calls: [{ + id: callId, + type: 'function', + function: { name: 'Read', arguments: JSON.stringify({ file_path: 'output/big.txt' }) }, + }], + }, + { role: 'tool', tool_call_id: callId, content: bigContent }, + ]); + // Two reads of the same file: ~12,400 tokens total against a safe limit of + // 8,000 — over the limit, but the file-read dedup stage can free one copy. + const messages = [...readPair('c1'), ...readPair('c2')]; + const res = await handleLLMError( + 'LLM request blocked before send: estimated prompt size 12,400 tokens exceeds safe limit 8,000 tokens (80% of context 10,000).', + { + movement: mv(undefined), + messages, + tools: [], + toolsUsed: [], + contextManager: new ContextManager({ limitTokens: 10_000 }), + promptGuardRatio: 0.8, + runIsolatedLlm: async () => 'summary', + }, + ); + expect(res).toBeNull(); + }); +}); + +// Reserve-cap follow-up: on large-context models the guard's re-check derived +// its threshold as `limit - min(ratioReserve, 32k)`, which is LOOSER than the +// client's actual safe limit (e.g. 230,144 vs 209,715 on a 262k model). A +// prompt inside that ~20k band made the guard say "within limits, nothing to +// shrink" even though real recovery (compacting a huge tool result) was +// possible. The recovery re-check must target the safe limit the client +// actually enforced, not a re-derived, capped one. +describe('handleLLMError — recovery honors the client-reported safe limit on large-context models', () => { + it('compacts down to the reported limit and continues instead of giving up', async () => { + const { handleLLMError } = await import('./context-control.js'); + // 262,144-token model: client safe limit 209,715; guard re-derivation + // with the 32k reserve cap would allow up to 230,144. + // One giant compactable tool result puts the prompt at ~215k — inside + // the disagreement band. + const bigToolResult = 'A'.repeat(750_000); // ~214,300 tokens + const messages: import('../../llm/openai-compat.js').Message[] = [ + { + role: 'assistant', + tool_calls: [{ + id: 'g1', + type: 'function', + function: { name: 'Grep', arguments: JSON.stringify({ pattern: 'x' }) }, + }], + }, + { role: 'tool', tool_call_id: 'g1', content: bigToolResult }, + ]; + const res = await handleLLMError( + 'LLM request blocked before send: estimated prompt size 214,500 tokens exceeds safe limit 209,715 tokens (80% of context 262,144).', + { + movement: mv(undefined), + messages, + tools: [], + toolsUsed: [], + contextManager: new ContextManager({ limitTokens: 262_144 }), + promptGuardRatio: 0.8, + runIsolatedLlm: async () => 'summary', + }, + ); + // Recovery succeeded by compaction — continue the loop. + expect(res).toBeNull(); + // The oversized tool result was actually replaced. + const toolMsg = messages.find((m) => m.role === 'tool'); + expect(typeof toolMsg?.content === 'string' && toolMsg.content.length).toBeLessThan(750_000); + }); +}); diff --git a/src/engine/agent-loop/context-control.ts b/src/engine/agent-loop/context-control.ts index 65e7f5f..1c16903 100644 --- a/src/engine/agent-loop/context-control.ts +++ b/src/engine/agent-loop/context-control.ts @@ -172,17 +172,44 @@ export async function handleLLMError( ): Promise { if (errorMessage.startsWith('LLM request blocked before send:')) { const parsedSafeLimit = parsePromptSafeLimitTokens(errorMessage); - const impliedRatio = parsedSafeLimit && ctx.contextManager - ? parsedSafeLimit / ctx.contextManager.getContextLimit() + const limitTokens = ctx.contextManager?.getContextLimit(); + const impliedRatio = parsedSafeLimit && limitTokens + ? parsedSafeLimit / limitTokens : ctx.promptGuardRatio; + // Recovery must target the safe limit the client actually enforced. The + // guard normally re-derives its threshold as `limit - min(ratioReserve, + // reserveCap)`, which on large-context models is LOOSER than the client's + // limit (e.g. 230,144 vs 209,715 on a 262k model) — the guard would then + // see the blocked prompt as "within limits" and shrink nothing. Pinning + // reserveCapTokens to `limit - parsedSafeLimit` makes the re-derived + // threshold equal the client's limit exactly. + const historySummarization = parsedSafeLimit && limitTokens + ? { ...ctx.safetyConfig?.historySummarization, reserveCapTokens: limitTokens - parsedSafeLimit } + : ctx.safetyConfig?.historySummarization; const recoveredGuard = await guardPromptBeforeSend(ctx.messages, ctx.tools, ctx.contextManager, { promptGuardRatio: impliedRatio, - historySummarization: ctx.safetyConfig?.historySummarization, + historySummarization, runIsolatedLlm: ctx.runIsolatedLlm, }); if (recoveredGuard.ok) { - logger.warn(`[agent-loop] movement=${ctx.movement.name} recovered from client prompt preflight block (deduped=${recoveredGuard.deduped} compacted=${recoveredGuard.compacted} summarized=${recoveredGuard.summarized}) estimated=${recoveredGuard.estimatedTokens}`); - return null; + const changedAnything = recoveredGuard.deduped || recoveredGuard.compacted || recoveredGuard.summarized; + if (changedAnything) { + logger.warn(`[agent-loop] movement=${ctx.movement.name} recovered from client prompt preflight block (deduped=${recoveredGuard.deduped} compacted=${recoveredGuard.compacted} summarized=${recoveredGuard.summarized}) estimated=${recoveredGuard.estimatedTokens}`); + return null; + } + // The client blocked the request but the local guard sees the prompt as + // within limits AND changed nothing — the estimators disagree. Resending + // the byte-identical request would be blocked again on every iteration + // (observed in production: 195 consecutive ~14ms failures straight to + // maxIterations). A recovery that changed nothing is not a recovery. + logger.warn(`[agent-loop] movement=${ctx.movement.name} client preflight blocked but local guard found nothing to shrink (estimated=${recoveredGuard.estimatedTokens}) — ending movement instead of resending an identical request`); + return await buildContextOverflowResult( + ctx.movement, + `${errorMessage}\n\nThe local prompt guard found nothing further to shrink; resending the identical request would spin until max iterations.`, + ctx.messages, + ctx.toolsUsed, + ctx.runIsolatedLlm, + ); } return await buildContextOverflowResult( ctx.movement, diff --git a/src/engine/agent-loop/delegate-runner.ts b/src/engine/agent-loop/delegate-runner.ts index 76b972b..5d6a255 100644 --- a/src/engine/agent-loop/delegate-runner.ts +++ b/src/engine/agent-loop/delegate-runner.ts @@ -121,8 +121,10 @@ export async function runDelegateSubAgent( : res.next === 'ASK' ? 'needs_user_input' : 'aborted'; + const resultPreview = (res.output ?? '').slice(0, 2000); + parentCtx.eventLogger?.emit('delegate_complete', { - delegateRunId, parentRunId, description: params.description, depth, next: res.next, status, + delegateRunId, parentRunId, description: params.description, depth, next: res.next, status, resultPreview, }); parentCallbacks?.onDelegateLifecycle?.({ delegateRunId, parentRunId, depth, description: params.description, status, diff --git a/src/engine/agent-loop/pending-states.test.ts b/src/engine/agent-loop/pending-states.test.ts index 765203c..72539a7 100644 --- a/src/engine/agent-loop/pending-states.test.ts +++ b/src/engine/agent-loop/pending-states.test.ts @@ -4,6 +4,7 @@ import type { DispatchOutcome } from './tool-dispatcher.js'; import { buildBrowserWaitResult, buildToolApprovalWaitResult, + buildPackageApprovalWaitResult, buildSubtaskWaitResult, buildCompleteAutoParkResult, } from './pending-states.js'; @@ -37,6 +38,20 @@ describe('buildToolApprovalWaitResult', () => { }); }); +describe('buildPackageApprovalWaitResult', () => { + it('parks for approval and clears the flag when set', () => { + const c = ctx({ pendingPackageApproval: { spec: 'requests==2.32.3' } }); + const r = buildPackageApprovalWaitResult(c, [], 'm1'); + expect(r?.next).toBe('WAITING_HUMAN_PACKAGE_REQUEST'); + expect(r?.waitReason).toBe('package_request'); + expect(String(r?.output)).toContain('requests==2.32.3'); + expect(c.pendingPackageApproval).toBeNull(); // flag consumed + }); + it('returns null when no package approval is pending', () => { + expect(buildPackageApprovalWaitResult(ctx(), [], 'm1')).toBeNull(); + }); +}); + describe('buildSubtaskWaitResult', () => { it('parks as WAIT_SUBTASKS (resume same movement) and clears the flag', () => { const c = ctx({ pendingSubtaskWait: true }); diff --git a/src/engine/agent-loop/pending-states.ts b/src/engine/agent-loop/pending-states.ts index d0fcbf1..00dbc2c 100644 --- a/src/engine/agent-loop/pending-states.ts +++ b/src/engine/agent-loop/pending-states.ts @@ -61,6 +61,31 @@ export function buildToolApprovalWaitResult( }; } +/** + * Package-request mechanism: RequestPackage set a pause flag → end the movement + * as waiting_human so a user can approve/deny the requested Python package + * inline. On approve the wheel is installed into the space overlay and the job + * resumes; the movement re-runs from the start (resumeMovement) with the + * package importable. Clears toolCtx.pendingPackageApproval. Mirrors + * buildToolApprovalWaitResult. + */ +export function buildPackageApprovalWaitResult( + toolCtx: ToolContext, + toolsUsed: string[], + movementName: string, +): MovementResult | null { + if (!toolCtx.pendingPackageApproval) return null; + const pending = toolCtx.pendingPackageApproval; + toolCtx.pendingPackageApproval = null; + logger.info(`[agent-loop] movement=${movementName} waiting_human for package approval: ${pending.spec}`); + return { + next: 'WAITING_HUMAN_PACKAGE_REQUEST', + output: `Python パッケージ "${pending.spec}" のインストール承認を待っています。`, + toolsUsed, + waitReason: 'package_request', + }; +} + /** * WaitSubTask set a park flag → end the movement as WAIT_SUBTASKS so the worker * parks this job (releasing the worker) until the spawned children finish, then diff --git a/src/engine/context/prompt-guard.test.ts b/src/engine/context/prompt-guard.test.ts index e2a1bce..4d2f432 100644 --- a/src/engine/context/prompt-guard.test.ts +++ b/src/engine/context/prompt-guard.test.ts @@ -328,3 +328,24 @@ describe('guardPromptBeforeSend', () => { expect(result.ok).toBe(false); }); }); + +// Livelock fix: the client-side preflight adds a fixed base overhead +// (PROMPT_BASE_OVERHEAD_TOKENS) that this guard historically did not count. +// A prompt landing inside that band passed here ("nothing to do") but was +// blocked at send time forever — the loop burned all iterations at ~10ms per +// spin. The guard must charge the same overhead as the client. +describe('guardPromptBeforeSend — client-preflight estimator parity', () => { + it('treats a prompt inside the last-overhead-band as over the limit instead of passing it through unchanged', async () => { + const cm = new ContextManager({ limitTokens: 1_000 }); + // maxPromptTokens = 1000 - min(1000*0.2, cap) = 800. + // A plain user message of ~750 tokens is under 800 on its own, but over + // once the client's base overhead is charged. Nothing is shrinkable + // (no duplicate reads, no oversized tool results, no summarizer hook), + // so the guard must report ok:false rather than "recovered". + const messages: Message[] = [ + { role: 'user', content: asciiApproxTokens(740) }, + ]; + const res = await guardPromptBeforeSend(messages, NO_TOOLS, cm); + expect(res.ok).toBe(false); + }); +}); diff --git a/src/engine/context/prompt-guard.ts b/src/engine/context/prompt-guard.ts index 24b6dfa..071491a 100644 --- a/src/engine/context/prompt-guard.ts +++ b/src/engine/context/prompt-guard.ts @@ -7,6 +7,7 @@ import { estimateTokensFromText, estimateMessagesTokens, estimateToolsTokens, + PROMPT_BASE_OVERHEAD_TOKENS, } from './token-estimate.js'; import { logger } from '../../logger.js'; @@ -106,10 +107,10 @@ export function parsePromptSafeLimitTokens(errorMessage: string): number | null */ export function compactOversizedToolResults( messages: Message[], - toolTokens: number, + fixedTokens: number, // non-message tokens: tool definitions + request base overhead maxPromptTokens: number, ): { changed: boolean; estimatedTokens: number; omittedCount: number } { - let estimatedTokens = estimateMessagesTokens(messages) + toolTokens; + let estimatedTokens = estimateMessagesTokens(messages) + fixedTokens; let changed = false; let omittedCount = 0; if (estimatedTokens <= maxPromptTokens) return { changed, estimatedTokens, omittedCount }; @@ -166,11 +167,15 @@ export async function guardPromptBeforeSend( const promptGuardRatio = options.promptGuardRatio ?? PROMPT_GUARD_RATIO_DEFAULT; // tools is built once per movement and never mutated, so JSON.stringify it // exactly once instead of recomputing inside every estimate call. - const toolTokens = estimateToolsTokens(tools); + // The fixed part of every estimate is tools + the client's per-request base + // overhead — the guard must count exactly what the client preflight counts + // (see estimateRequestTokens), or prompts in the last-overhead-band pass + // here but are blocked at send time forever. + const fixedTokens = estimateToolsTokens(tools) + PROMPT_BASE_OVERHEAD_TOKENS; if (!contextManager) { return { ok: true, - estimatedTokens: estimateMessagesTokens(messages) + toolTokens, + estimatedTokens: estimateMessagesTokens(messages) + fixedTokens, compacted: false, deduped: false, summarized: false, @@ -180,21 +185,21 @@ export async function guardPromptBeforeSend( const reserveCapTokens = options.historySummarization?.reserveCapTokens ?? RESERVE_CAP_TOKENS_DEFAULT; const maxPromptTokens = computeMaxPromptTokens(limitTokens, promptGuardRatio, reserveCapTokens); - let estimated = estimateMessagesTokens(messages) + toolTokens; + let estimated = estimateMessagesTokens(messages) + fixedTokens; if (estimated <= maxPromptTokens) { return { ok: true, estimatedTokens: estimated, compacted: false, deduped: false, summarized: false }; } const dedup = dedupeFileReads(messages); if (dedup.changed) { - estimated = estimateMessagesTokens(messages) + toolTokens; + estimated = estimateMessagesTokens(messages) + fixedTokens; logger.info(`[prompt-guard] file-read dedup replaced=${dedup.replacedCount} freedChars=${dedup.freedChars} estimated=${estimated}`); } if (estimated <= maxPromptTokens) { return { ok: true, estimatedTokens: estimated, compacted: false, deduped: dedup.changed, summarized: false }; } - const compacted = compactOversizedToolResults(messages, toolTokens, maxPromptTokens); + const compacted = compactOversizedToolResults(messages, fixedTokens, maxPromptTokens); let summarized = false; if (compacted.changed) { const feedback = buildPromptLimitAgentInstruction(compacted.estimatedTokens, maxPromptTokens); @@ -237,7 +242,7 @@ export async function guardPromptBeforeSend( }); if (summary.summarized) { summarized = true; - estimated = estimateMessagesTokens(messages) + toolTokens; + estimated = estimateMessagesTokens(messages) + fixedTokens; logger.info(`[prompt-guard] history summarization complete freedChars=${summary.freedChars} estimated=${estimated}`); if (estimated <= maxPromptTokens) { return { diff --git a/src/engine/context/token-estimate.test.ts b/src/engine/context/token-estimate.test.ts index c534498..a520f7f 100644 --- a/src/engine/context/token-estimate.test.ts +++ b/src/engine/context/token-estimate.test.ts @@ -4,10 +4,12 @@ import { estimateMessagesTokens, estimateMessageTokens, estimatePromptTokens, + estimateRequestTokens, estimateTokensFromChars, estimateTokensFromText, estimateToolsTokens, IMAGE_CONTENT_TOKENS, + PROMPT_BASE_OVERHEAD_TOKENS, UNKNOWN_CHARS_TO_TOKENS_FACTOR, } from './token-estimate.js'; @@ -183,3 +185,24 @@ describe('estimatePromptTokens', () => { expect(estimatePromptTokens([], [])).toBe(1); // only "[]" tools serialization }); }); + +// Livelock fix: the LLM client's send-time preflight charges a fixed base +// overhead per request on top of message + tool content. Local guards must +// count the exact same total, or a prompt landing in the last +// PROMPT_BASE_OVERHEAD_TOKENS below the safe limit passes the guard but is +// blocked by the client forever (burning iterations at ~10ms per spin). +describe('estimateRequestTokens', () => { + it('adds the request base overhead on top of the prompt estimate', () => { + const messages: Message[] = [{ role: 'user', content: 'hello world' }]; + const tools: ToolDef[] = [{ + type: 'function', + function: { name: 'X', description: 'd', parameters: {} }, + }]; + expect(estimateRequestTokens(messages, tools)) + .toBe(estimatePromptTokens(messages, tools) + PROMPT_BASE_OVERHEAD_TOKENS); + }); + + it('pins the overhead to the value the client preflight has always charged', () => { + expect(PROMPT_BASE_OVERHEAD_TOKENS).toBe(128); + }); +}); diff --git a/src/engine/context/token-estimate.ts b/src/engine/context/token-estimate.ts index d9eeffa..14c2519 100644 --- a/src/engine/context/token-estimate.ts +++ b/src/engine/context/token-estimate.ts @@ -89,3 +89,21 @@ export function estimateToolsTokens(tools: ToolDef[]): number { export function estimatePromptTokens(messages: Message[], tools: ToolDef[]): number { return estimateMessagesTokens(messages) + estimateToolsTokens(tools); } + +/** + * Fixed per-request overhead (chat-template scaffolding, request framing) + * charged by the send-time preflight in OpenAICompatClient. Every local + * guard that compares an estimate against the same safe limit MUST include + * this, or prompts landing within this band pass the guard but are blocked + * at send time on every retry — an unrecoverable livelock. + */ +export const PROMPT_BASE_OVERHEAD_TOKENS = 128; + +/** + * What a request actually costs at the client preflight: prompt content + * plus the fixed base overhead. Single source of truth shared by the client + * preflight and the agent-loop prompt guard. + */ +export function estimateRequestTokens(messages: Message[], tools: ToolDef[] = []): number { + return estimatePromptTokens(messages, tools) + PROMPT_BASE_OVERHEAD_TOKENS; +} diff --git a/src/engine/piece-runner.ts b/src/engine/piece-runner.ts index cc6c763..ccbf432 100644 --- a/src/engine/piece-runner.ts +++ b/src/engine/piece-runner.ts @@ -398,6 +398,22 @@ export async function runPiece( category: 'requested' | 'blocked' | 'unknown'; status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; }) => void; + /** Package-request mechanism: record an agent Python package request. + * Bound to task/job/space by the worker only when the feature is enabled + * AND a space is resolvable; undefined => feature unavailable this run. */ + recordPackageRequest?: (req: { + pieceName?: string | null; + movementName?: string | null; + spec: string; + normalizedName: string; + reason?: string | null; + status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; + }) => void; + /** Package-request mechanism: packages already installed in the space + * overlay (name→spec, version-aware). undefined => feature off. */ + installedPackages?: Array<{ name: string; spec: string }>; + /** Package-request mechanism: names declined for this job (deny loop guard). */ + declinedPackages?: string[]; }, ): Promise { const movementHistory: Array<{ name: string; result: MovementResult }> = []; @@ -769,6 +785,22 @@ function prepareMovementContext( category: 'requested' | 'blocked' | 'unknown'; status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; }) => void; + /** Package-request mechanism: record an agent Python package request. + * Bound to task/job/space by the worker only when the feature is enabled + * AND a space is resolvable; undefined => feature unavailable this run. */ + recordPackageRequest?: (req: { + pieceName?: string | null; + movementName?: string | null; + spec: string; + normalizedName: string; + reason?: string | null; + status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; + }) => void; + /** Package-request mechanism: packages already installed in the space + * overlay (name→spec, version-aware). undefined => feature off. */ + installedPackages?: Array<{ name: string; spec: string }>; + /** Package-request mechanism: names declined for this job (deny loop guard). */ + declinedPackages?: string[]; /** File-provenance read IO (workspace-bound). Threaded into ToolContext. */ fileProvenance?: import('./tools/core.js').FileProvenanceIO; /** File-provenance write recorder. Bound to task/job/space by the worker; @@ -835,6 +867,14 @@ function prepareMovementContext( recordToolRequest: options?.recordToolRequest ? (req) => options.recordToolRequest!({ ...req, pieceName: piece.name, movementName: movementDef.name }) : undefined, + // Package-request mechanism: recorder wrapped to inject piece + movement + // (worker binds task/job/space); the installed-set is space-wide (no per- + // movement injection needed). + recordPackageRequest: options?.recordPackageRequest + ? (req) => options.recordPackageRequest!({ ...req, pieceName: piece.name, movementName: movementDef.name }) + : undefined, + installedPackages: options?.installedPackages, + declinedPackages: options?.declinedPackages, toolsConfig, eventLogger: options?.eventLogger, searchFilter: options?.searchFilter, @@ -1193,6 +1233,22 @@ function mapMovementResult( }; } + if (result.next === 'WAITING_HUMAN_PACKAGE_REQUEST') { + logger.info(`[piece-runner] piece=${piece.name} WAITING_HUMAN_PACKAGE_REQUEST movement=${currentMovementName}`); + return { + kind: 'done', + result: { + status: 'waiting_human', + finalOutput: result.output, + movementHistory, + resumeMovement: currentMovementName, + abortReason: null, + contextActions, + waitReason: result.waitReason ?? 'package_request', + }, + }; + } + if (result.next === null) { logger.warn(`[piece-runner] piece=${piece.name} movement=${currentMovementName} returned null next`); return { diff --git a/src/engine/skills.ts b/src/engine/skills.ts index dac2f4c..e7e0658 100644 --- a/src/engine/skills.ts +++ b/src/engine/skills.ts @@ -37,6 +37,17 @@ function parseSkillFile(filePath: string, source: 'system' | 'user', dirPath: st } } +/** + * Scan a skills directory (depth 1: `{dir}/{name}/SKILL.md` dirs + flat + * `{dir}/{name}.md` files) into catalog entries. Entry names come from the + * SKILL.md frontmatter `name`, which can differ from the folder/file name — + * callers that need to locate a skill on disk by its catalog name must use + * the returned entry's `dirPath`/`filePath` instead of reconstructing paths. + */ +export function scanSkillsDir(dir: string, source: 'system' | 'user'): SkillEntry[] { + return scanDir(dir, source); +} + function scanDir(dir: string, source: 'system' | 'user'): SkillEntry[] { if (!existsSync(dir)) return []; const entries = readdirSync(dir); diff --git a/src/engine/tools/core.test.ts b/src/engine/tools/core.test.ts index ce32f83..ca087be 100644 --- a/src/engine/tools/core.test.ts +++ b/src/engine/tools/core.test.ts @@ -1339,3 +1339,51 @@ describe('Read: document formats vs text-only range params', () => { expect(result?.output ?? '').not.toContain('offset/limit'); }); }); + +// Livelock fix follow-up: Grep was the only core output tool without a token +// budget — Read and Bash truncate, Grep returned every matching line raw. A +// broad pattern over a large workspace injects an unbounded blob into the +// conversation in one step, which is exactly what pushes the prompt into the +// preflight danger band. +describe('Grep output budget', () => { + let ws = ''; + + afterEach(() => { + if (ws) { + fs.rmSync(ws, { recursive: true, force: true }); + ws = ''; + } + }); + + it('truncates oversized match lists to the context budget with a notice', async () => { + ws = makeWorkspace(); + fs.mkdirSync(path.join(ws, 'output'), { recursive: true }); + // ~40k matching lines × ~30 chars ≈ 1.2MB — far over the 60k-token + // absolute cap that applies when no contextManager is present. + const lines: string[] = []; + for (let i = 0; i < 40_000; i++) { + lines.push(`needle match line ${i} padding-padding`); + } + fs.writeFileSync(path.join(ws, 'output', 'big.txt'), lines.join('\n'), 'utf-8'); + + const result = await executeCoreTools('Grep', { pattern: 'needle', path: 'output' }, makeContext(ws)); + + expect(result?.isError).toBe(false); + expect(result?.output).toContain('[自動切り詰め]'); + expect(result?.output.length ?? 0).toBeLessThan(1_000_000); + // The head of the match list is still returned. + expect(result?.output).toContain('big.txt:1:'); + }); + + it('returns small match lists untouched', async () => { + ws = makeWorkspace(); + fs.mkdirSync(path.join(ws, 'output'), { recursive: true }); + fs.writeFileSync(path.join(ws, 'output', 'small.txt'), 'one needle here\n', 'utf-8'); + + const result = await executeCoreTools('Grep', { pattern: 'needle', path: 'output' }, makeContext(ws)); + + expect(result?.isError).toBe(false); + expect(result?.output).not.toContain('[自動切り詰め]'); + expect(result?.output).toContain('small.txt:1:'); + }); +}); diff --git a/src/engine/tools/core.ts b/src/engine/tools/core.ts index c217635..822bb7e 100644 --- a/src/engine/tools/core.ts +++ b/src/engine/tools/core.ts @@ -261,6 +261,41 @@ export interface ToolContext { * then resumes the SAME movement. Mirrors pendingToolApproval. */ pendingSubtaskWait?: boolean; + /** + * Package-request mechanism: recorder for agent-declared Python package + * requests (RequestPackage). Bound by the worker (task/job/space) ONLY when + * the feature is enabled AND the run has a resolvable space. Undefined => + * feature unavailable for this run (RequestPackage records nothing and tells + * the agent to proceed). piece-runner wraps it to inject piece + movement. + */ + recordPackageRequest?: (req: { + spec: string; + normalizedName: string; + reason?: string | null; + status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; + }) => void; + /** + * Package-request mechanism: packages already installed in this workspace's + * overlay (name→canonical spec, from the space desired-state at job start). + * Version-aware: a pinned request (`name==version`) is only "already + * installed" on an exact spec match; a bare-name request matches any version. + * Lets RequestPackage short-circuit (and after a resume, recognise the + * just-approved one). Undefined when the feature is off. + */ + installedPackages?: Array<{ name: string; spec: string }>; + /** + * Package-request mechanism: PEP 503 normalized names already declined + * (denied / auto_denied) for THIS job. RequestPackage short-circuits a + * re-request for one of these instead of re-parking — otherwise a deny would + * loop (deny → movement re-runs → re-request → park). Undefined => none. + */ + declinedPackages?: string[]; + /** + * Set by RequestPackage to signal the movement loop to pause (waiting_human) + * for inline approval of the named package. Read + reset by agent-loop. + * Mirrors pendingToolApproval. + */ + pendingPackageApproval?: { spec: string } | null; /** Per-task option: when true, MCP tools are not loaded/dispatched. */ mcpDisabled?: boolean; /** Per-task option: when true, skill index is not injected into the system prompt. */ @@ -920,8 +955,8 @@ export function checkBlockedInstallPatterns(command: string): void { throw new Error( `Package installation is not available in the sandbox. ` + `These are preinstalled and importable: ${PREINSTALLED_HINT}. ` + - `If you need another package, ask an admin to add it to ` + - `runtime/python-requirements.txt and rebuild — do not retry the install.` + `If you need another package, request it with RequestPackage({ name: "", reason: "..." }) ` + + `— an approver installs it into this workspace and the run resumes. Do not retry the install here.` ); } } @@ -1876,7 +1911,21 @@ function executeGrep(input: Record, ctx: ToolContext): ToolResu if (results.length === 0) { return { output: '(no matches)', isError: false }; } - return { output: results.join('\n'), isError: false }; + // Read と Bash は予算切り詰めがあるのに Grep だけ無制限だった。緩いパターン + // 1回で数十万文字が会話に流れ込み、プロンプトを preflight 上限際まで押し込む + // 起爆剤になっていたので、同じトークン予算で切り詰める。 + const budgetTokens = getToolOutputBudgetTokens(ctx); + const raw = results.join('\n'); + const { text, truncated } = truncateToBudget(raw, budgetTokens, { + sourceLabel: `Grep 結果 (pattern: ${pattern})`, + totalLines: results.length, + continuationHint: + 'マッチが多すぎます。pattern をより具体的にする、glob でファイルを絞る、path でディレクトリを絞る、のいずれかで検索範囲を狭めてください', + }); + if (truncated) { + logger.info(`[grep] truncated matches=${results.length} original_chars=${raw.length} budget_tokens=${budgetTokens}`); + } + return { output: text, isError: false }; } // コアツールを実行 (Read, Write, Edit, Bash, Glob, Grep) diff --git a/src/engine/tools/image.ts b/src/engine/tools/image.ts index de441c7..b640cf3 100644 --- a/src/engine/tools/image.ts +++ b/src/engine/tools/image.ts @@ -275,6 +275,8 @@ export async function callVisionModel( const toolsConfig = ctx.toolsConfig ?? {}; const visionModel = toolsConfig.visionModel ?? 'qwen2-vl:8b-instruct'; + // Neutral placeholder — set tools.vision_base_url in config.yaml for a real endpoint. + // (Workers usually override this with their own endpoint; see worker.ts) const visionBaseUrl = toolsConfig.visionBaseUrl ?? 'http://10.0.0.10:11434/v1'; const visionTimeout = (toolsConfig.visionTimeout ?? 60) * 1000; const visionMaxTokens = toolsConfig.visionMaxTokens ?? 1024; diff --git a/src/engine/tools/index.ts b/src/engine/tools/index.ts index 5864b6d..7319159 100644 --- a/src/engine/tools/index.ts +++ b/src/engine/tools/index.ts @@ -245,6 +245,15 @@ async function getToolRequestModule(): Promise { return _toolRequestModule; } +let _packageRequestModule: ToolModule | null | undefined; +async function getPackageRequestModule(): Promise { + if (_packageRequestModule === undefined) { + _packageRequestModule = await tryLoadModule('./package-request.js'); + if (_packageRequestModule) logger.debug('[tools/index] package-request module loaded'); + } + return _packageRequestModule; +} + /** * Snapshot of all known static tool names, refreshed every getToolDefs() call * (which runs at movement start, before any tool executes). Injected into @@ -418,6 +427,9 @@ export async function getToolDefs( const toolRequestMod = await getToolRequestModule(); if (toolRequestMod) Object.assign(allDefs, toolRequestMod.TOOL_DEFS); + const packageRequestMod = await getPackageRequestModule(); + if (packageRequestMod) Object.assign(allDefs, packageRequestMod.TOOL_DEFS); + // Refresh the known-tool snapshot for RequestTool classification. // // CONCURRENCY INVARIANT (must hold): this is taken HERE — after every static @@ -444,7 +456,8 @@ export async function getToolDefs( // - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece) // ただし他の piece からも参照できるようにメタ扱い // - RequestTool: 足りないツールの要求を記録(タスク詳細/ピース集計で可視化) - const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'SearchTaskConversation', 'ReadTaskConversation', 'SearchWorkspaceTasks', 'GetFileProvenance', 'ListWorkspaceFiles', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool']; + // - RequestPackage: 足りない Python パッケージ(wheel)を申請(承認でスペース overlay へ install) + const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'SearchTaskConversation', 'ReadTaskConversation', 'SearchWorkspaceTasks', 'GetFileProvenance', 'ListWorkspaceFiles', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool', 'RequestPackage']; const effectiveAllowed = [...allowedTools]; for (const meta of META_TOOLS) { if (!effectiveAllowed.includes(meta) && meta in allDefs) { @@ -674,6 +687,14 @@ async function executeToolInner( if (toolRequestResult !== null) return toolRequestResult; } + // RequestPackage: 足りない Python パッケージ(wheel)を申請。承認でスペース + // overlay へ install され、ジョブ再開で import 可能になる。 + const packageRequestMod = await getPackageRequestModule(); + if (packageRequestMod) { + const packageRequestResult = await packageRequestMod.executeTool(name, input, ctx); + if (packageRequestResult !== null) return packageRequestResult; + } + // user-folder ツール (ListUserAssets / RunUserScript) const userFolderMod = await getUserFolderModule(); if (userFolderMod) { diff --git a/src/engine/tools/package-request.test.ts b/src/engine/tools/package-request.test.ts new file mode 100644 index 0000000..a0aed29 --- /dev/null +++ b/src/engine/tools/package-request.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { ToolContext } from './core.js'; +import { executeTool } from './package-request.js'; + +const ctx = (over: Partial = {}): ToolContext => + ({ workspacePath: '/ws', ...over } as ToolContext); + +describe('RequestPackage tool', () => { + it('ignores non-RequestPackage names (returns null)', async () => { + expect(await executeTool('Bash', { name: 'x', reason: 'y' }, ctx())).toBeNull(); + }); + + it('requires name and reason', async () => { + expect((await executeTool('RequestPackage', { reason: 'r' }, ctx()))?.isError).toBe(true); + expect((await executeTool('RequestPackage', { name: 'requests' }, ctx()))?.isError).toBe(true); + }); + + it('rejects an invalid / shadowing spec with a hard error (records nothing)', async () => { + const rec = vi.fn(); + // "os" shadows the stdlib → assertNotShadowing throws. + const r = await executeTool('RequestPackage', { name: 'os', reason: 'need it' }, ctx({ recordPackageRequest: rec, toolApprovalInteractive: true })); + expect(r?.isError).toBe(true); + expect(rec).not.toHaveBeenCalled(); + // a range operator is also rejected. + const r2 = await executeTool('RequestPackage', { name: 'requests>=2', reason: 'x' }, ctx({ recordPackageRequest: rec, toolApprovalInteractive: true })); + expect(r2?.isError).toBe(true); + }); + + it('reports feature unavailable when no recorder is bound (records nothing)', async () => { + const r = await executeTool('RequestPackage', { name: 'requests', reason: 'http' }, ctx()); + expect(r?.isError).toBe(false); + expect(String(r?.output)).toContain('利用できません'); + }); + + it('short-circuits an already-installed package (exact spec match)', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'requests', spec: 'requests==2.32.3' }], toolApprovalInteractive: true }); + const r = await executeTool('RequestPackage', { name: 'requests==2.32.3', reason: 'http' }, c); + expect(r?.isError).toBe(false); + expect(String(r?.output)).toContain('インストール済み'); + expect(rec).not.toHaveBeenCalled(); + expect(c.pendingPackageApproval).toBeUndefined(); // no park + }); + + it('short-circuits a bare-name request when any version is installed', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'requests', spec: 'requests==2.32.3' }], toolApprovalInteractive: true }); + const r = await executeTool('RequestPackage', { name: 'requests', reason: 'http' }, c); + expect(String(r?.output)).toContain('インストール済み'); + expect(c.pendingPackageApproval).toBeUndefined(); + }); + + it('does NOT park a pinned request for a different version than the one installed', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'httpx', spec: 'httpx==0.27.0' }], toolApprovalInteractive: true }); + const r = await executeTool('RequestPackage', { name: 'httpx==0.28.0', reason: 'need newer' }, c); + expect(r?.isError).toBe(false); + expect(String(r?.output)).toContain('固定'); + expect(rec).not.toHaveBeenCalled(); + expect(c.pendingPackageApproval).toBeUndefined(); // no dead-end park + }); + + it('does NOT re-park a package already declined for this job (deny loop guard)', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, declinedPackages: ['httpx'], toolApprovalInteractive: true }); + const r = await executeTool('RequestPackage', { name: 'httpx==0.28.0', reason: 'retry' }, c); + expect(r?.isError).toBe(false); + expect(String(r?.output)).toContain('却下'); + expect(rec).not.toHaveBeenCalled(); + expect(c.pendingPackageApproval).toBeUndefined(); + }); + + it('parks for approval on an interactive run (records pending)', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, toolApprovalInteractive: true }); + const r = await executeTool('RequestPackage', { name: 'Requests==2.32.3', reason: 'http calls' }, c); + expect(r?.isError).toBe(false); + expect(c.pendingPackageApproval).toEqual({ spec: 'requests==2.32.3' }); + expect(rec).toHaveBeenCalledWith( + expect.objectContaining({ spec: 'requests==2.32.3', normalizedName: 'requests', status: 'pending', reason: 'http calls' }), + ); + }); + + it('records auto_denied and does NOT park on a non-interactive run', async () => { + const rec = vi.fn(); + const c = ctx({ recordPackageRequest: rec, toolApprovalInteractive: false }); + // httpx is neither stdlib nor preinstalled, so it passes the shadowing guard. + const r = await executeTool('RequestPackage', { name: 'httpx', reason: 'async http' }, c); + expect(r?.isError).toBe(false); + expect(c.pendingPackageApproval).toBeUndefined(); + expect(rec).toHaveBeenCalledWith(expect.objectContaining({ normalizedName: 'httpx', status: 'auto_denied' })); + }); +}); diff --git a/src/engine/tools/package-request.ts b/src/engine/tools/package-request.ts new file mode 100644 index 0000000..18666e0 --- /dev/null +++ b/src/engine/tools/package-request.ts @@ -0,0 +1,146 @@ +/** + * RequestPackage — META_TOOL for the agent-initiated Python package request. + * + * When an agent needs a Python wheel that is neither preinstalled nor already + * in the workspace overlay, it calls RequestPackage({ name, reason }). The + * request is recorded so it surfaces in the task detail; when the run can reach + * a user (interactive) the movement PARKS for approval. A task-write approver + * (owner / admin / space editor) then approves — which installs the wheel into + * THIS workspace's per-space overlay (reusing PR2's installSpacePackages: + * bwrap-isolated, wheels-only, fixed index, stdlib/preinstalled shadowing + * guards) and resumes the parked job so the movement re-runs with the package + * importable. The agent never installs anything itself. + * + * Gating (all runtime, so the tool def stays unconditional — see the + * concurrency invariant in tools/index.ts): + * - `ctx.recordPackageRequest` unset → feature unavailable for this run + * (disabled globally, or no resolvable space) → record nothing, proceed. + * - name already in `ctx.installedPackages` → already installed, just import. + * - `ctx.toolApprovalInteractive` → park (pendingPackageApproval). + * - otherwise (subtask / headless) → record auto_denied, proceed without. + * + * The spec is validated (parseAndValidateSpec + assertNotShadowing) HERE and + * re-validated at approval time — the DB stores the canonical spec string. + */ + +import { ToolDef } from '../../llm/openai-compat.js'; +import type { ToolContext, ToolResult } from './core.js'; +import { parseAndValidateSpec, assertNotShadowing } from '../python-packages.js'; + +const REQUEST_PACKAGE_DEF: ToolDef = { + type: 'function', + function: { + name: 'RequestPackage', + description: + 'preinstalled に無い Python ライブラリ(wheel)が必要なとき name(例: requests または requests==2.32.3)と理由(reason)を渡して申請する。ユーザーが応答できる実行では承認を求め、承認されればこのワークスペースに入り import できる。詳細は ReadToolDoc({ name: "RequestPackage" })。', + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'パッケージ名。厳密固定は "name==version"(例: pandas==2.2.2)。バージョン省略可。' }, + reason: { type: 'string', description: 'なぜそのパッケージが必要か(具体的に)。' }, + }, + required: ['name', 'reason'], + }, + }, +}; + +export const TOOL_DEFS: Record = { + RequestPackage: REQUEST_PACKAGE_DEF, +}; + +export async function executeTool( + name: string, + input: Record, + ctx: ToolContext, +): Promise { + if (name !== 'RequestPackage') return null; + + const pkgName = typeof input['name'] === 'string' ? input['name'].trim() : ''; + const reason = typeof input['reason'] === 'string' ? input['reason'].trim() : ''; + if (!pkgName) return { output: 'RequestPackage: name は必須です。', isError: true }; + if (!reason) return { output: 'RequestPackage: reason(なぜ必要か)は必須です。', isError: true }; + + // Validate the spec (grammar + shadowing) up front. A HARD ERROR so the agent + // does not mistake a rejected spec for a pending approval. + let parsed; + try { + parsed = parseAndValidateSpec(pkgName); + assertNotShadowing(parsed.normalizedName); + } catch (e) { + return { + output: + `"${pkgName}" は申請できません: ${(e as Error).message}。` + + ` 厳密固定 "name==version" のみ・標準ライブラリや同梱パッケージと同名は不可です。`, + isError: true, + }; + } + + // Feature unavailable for this run (disabled globally, or no resolvable space). + // recordPackageRequest is bound by the worker only when both hold. `?.` would + // be a silent no-op, so gate explicitly and DON'T claim it was recorded. + if (!ctx.recordPackageRequest) { + return { + output: + `Python パッケージの追加はこの実行では利用できません。` + + ` 標準ライブラリや同梱パッケージで代替するか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。`, + isError: false, + }; + } + + // Already declined for THIS job → don't re-park (deny loop guard). The user + // already said no; proceed without it. + if (ctx.declinedPackages?.includes(parsed.normalizedName)) { + return { + output: + `"${parsed.normalizedName}" の追加は既にこのタスクで却下されています。再申請せず、このパッケージ無しで進めてください(達成できない場合は complete({status:"needs_user_input"}))。`, + isError: false, + }; + } + + // Already installed in this workspace overlay → nothing to approve. + // Version-aware: a pinned request needs an exact spec match; a bare-name + // request is satisfied by any installed version. A pinned request for a + // DIFFERENT version than the one already pinned cannot be satisfied (only one + // version per package), so short-circuit WITHOUT parking (the approve path + // couldn't install it anyway) and tell the agent to use the existing one. + const sameName = (ctx.installedPackages ?? []).filter((p) => p.name === parsed.normalizedName); + if (sameName.length > 0) { + const requestIsPinned = parsed.spec.includes('=='); + const exact = sameName.some((p) => p.spec === parsed.spec); + if (exact || !requestIsPinned) { + return { + output: `"${parsed.normalizedName}" は既にこのワークスペースにインストール済みです(${sameName[0].spec})。そのまま import して使ってください。`, + isError: false, + }; + } + return { + output: + `"${parsed.normalizedName}" は既に ${sameName[0].spec} でこのワークスペースに固定されています。` + + ` 1 パッケージにつき 1 バージョンのみです。別バージョンが必要なら管理者が 設定 → Python で入れ替える必要があります。今は既存版で進めてください。`, + isError: false, + }; + } + + // Interactive run: pause for inline approval. On approve the wheel is + // installed into the space overlay and the job resumes. + if (ctx.toolApprovalInteractive) { + ctx.recordPackageRequest({ spec: parsed.spec, normalizedName: parsed.normalizedName, reason, status: 'pending' }); + ctx.pendingPackageApproval = { spec: parsed.spec }; + return { + output: + `"${parsed.spec}" のインストール承認を待っています。` + + ` 承認されればこのワークスペースに追加され import して続行でき、拒否されればそのパッケージ無しで進めることになります。`, + isError: false, + }; + } + + // Non-interactive (subtask / headless): record so the user finds it later, + // and proceed without the package. + ctx.recordPackageRequest({ spec: parsed.spec, normalizedName: parsed.normalizedName, reason, status: 'auto_denied' }); + return { + output: + `"${parsed.spec}" のインストール要求を記録しました(この実行では自動で承認できません)。` + + ` このパッケージ無しで進めるか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。`, + isError: false, + }; +} diff --git a/src/engine/tools/skills.materialize-swap.test.ts b/src/engine/tools/skills.materialize-swap.test.ts new file mode 100644 index 0000000..9a371f6 --- /dev/null +++ b/src/engine/tools/skills.materialize-swap.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { tmpdir } from 'os'; +import { describe, expect, it, afterEach, vi } from 'vitest'; +import { SkillCatalog } from '../skills.js'; +import { executeSkillTool } from './skills.js'; +import type { ToolContext } from './core.js'; + +// Failure injection for the swap step: renameSync throws once when the +// destination matches, everything else passes through to the real fs. +const swapState = vi.hoisted(() => ({ failRenameToSuffix: null as string | null })); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: (src: fs.PathLike, dst: fs.PathLike) => { + if (swapState.failRenameToSuffix && String(dst).endsWith(swapState.failRenameToSuffix)) { + swapState.failRenameToSuffix = null; + throw new Error('simulated EIO on rename'); + } + return actual.renameSync(src, dst); + }, + }; +}); + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(tmpdir(), 'skill-swap-test-')); +} + +function writeSkill(dir: string, filename: string, content: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, filename), content, 'utf-8'); +} + +const DIR_SKILL = `--- +name: tdd +description: TDD workflow +--- + +## Steps +`; + +describe('materializeSkill swap failure', () => { + const dirs: string[] = []; + afterEach(() => { + swapState.failRenameToSuffix = null; + for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); + dirs.length = 0; + }); + + it('restores the previous copy when the swap rename fails', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const first = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(first!.isError).toBe(false); + + // Source updated; the tmp→dest swap will fail once (e.g. transient FS error). + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2\n'); + swapState.failRenameToSuffix = `${path.sep}skills${path.sep}tdd`; + + const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(second!.isError).toBe(false); + expect(second!.output).toContain('コピーできませんでした'); + // The previous copy must be back in place, not lost. + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('v1'); + }); +}); diff --git a/src/engine/tools/skills.test.ts b/src/engine/tools/skills.test.ts index 31c7112..6dc1096 100644 --- a/src/engine/tools/skills.test.ts +++ b/src/engine/tools/skills.test.ts @@ -90,6 +90,210 @@ describe('ReadSkill tool', () => { expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'))).toBe(true); }); + it('re-materializes when the source skill changed since the copy', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n'); + writeSkill(path.join(skillDir, 'scripts'), 'removed-later.sh', '#!/bin/sh\necho old\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const first = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(first!.isError).toBe(false); + + // Another task updates the skill in the store: one file rewritten, one removed. + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2 updated\n'); + fs.rmSync(path.join(skillDir, 'scripts', 'removed-later.sh')); + + const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(second!.isError).toBe(false); + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('v2 updated'); + // Files deleted at the source must not linger in the refreshed copy. + expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'removed-later.sh'))).toBe(false); + }); + + it('keeps the existing copy (including local edits) when the source is unchanged', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + // The agent tweaks its workspace copy; an unchanged source must not clobber it. + fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), '#!/bin/sh\necho local edit\n'); + + const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(second!.isError).toBe(false); + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('local edit'); + }); + + it('keeps the copy when only source mtimes changed (content identical)', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), '#!/bin/sh\necho local edit\n'); + + // e.g. the skill store was re-synced: identical bytes, new timestamps. + const later = new Date(Date.now() + 60_000); + fs.utimesSync(path.join(skillDir, 'scripts', 'run.sh'), later, later); + fs.utimesSync(path.join(skillDir, 'SKILL.md'), later, later); + + const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(second!.isError).toBe(false); + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('local edit'); + }); + + it('rejects an over-cap skill with the copy-limit note before touching the workspace', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + // Sparse 51MB file: over the 50MB cap without hitting the disk for real. + writeSkill(path.join(skillDir, 'scripts'), 'big.bin', ''); + fs.truncateSync(path.join(skillDir, 'scripts', 'big.bin'), 51 * 1024 * 1024); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(result!.isError).toBe(false); + expect(result!.output).toContain('50MB'); + expect(fs.existsSync(path.join(workspace, 'skills', 'tdd'))).toBe(false); + }); + + it('keeps the previous copy intact when refreshing fails mid-copy', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + + // Source changes, but one file becomes unreadable → the copy itself fails. + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2\n'); + fs.chmodSync(path.join(skillDir, 'scripts', 'run.sh'), 0o000); + try { + const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(second!.isError).toBe(false); + expect(second!.output).toContain('コピーできませんでした'); + // The previously materialized copy must survive a failed refresh. + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('v1'); + } finally { + fs.chmodSync(path.join(skillDir, 'scripts', 'run.sh'), 0o644); + } + }); + + it('does not clobber a skill-owned .skill-src-signature file', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(skillDir, '.skill-src-signature', 'skill-owned data\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(result!.isError).toBe(false); + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', '.skill-src-signature'), 'utf-8'); + expect(copied).toBe('skill-owned data\n'); + // And the up-to-date check still holds on the second read (no refresh loop). + fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'marker.txt'), 'local\n'); + executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'marker.txt'))).toBe(true); + }); + + it('sweeps abandoned tmp dirs but leaves fresh ones (concurrent copy in flight)', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n'); + + // Leftover from a crashed run (old) and one from a copy in flight (fresh). + const staleTmp = path.join(workspace, 'skills', '.tdd.tmp-dead'); + const freshTmp = path.join(workspace, 'skills', '.tdd.tmp-live'); + writeSkill(staleTmp, 'SKILL.md', 'stale'); + writeSkill(freshTmp, 'SKILL.md', 'fresh'); + const old = new Date(Date.now() - 60 * 60_000); + fs.utimesSync(staleTmp, old, old); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(result!.isError).toBe(false); + expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'SKILL.md'))).toBe(true); + expect(fs.existsSync(staleTmp)).toBe(false); + expect(fs.existsSync(freshTmp)).toBe(true); + }); + + it('refreshes a legacy copy that predates signature tracking', () => { + const systemDir = makeTempDir(); + const userRoot = makeTempDir(); + const workspace = makeTempDir(); + dirs.push(systemDir, userRoot, workspace); + + const skillDir = path.join(systemDir, 'tdd'); + writeSkill(skillDir, 'SKILL.md', DIR_SKILL); + writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho fresh\n'); + + // Simulate a copy left behind by an older build (no signature file). + writeSkill(path.join(workspace, 'skills', 'tdd'), 'SKILL.md', DIR_SKILL); + writeSkill(path.join(workspace, 'skills', 'tdd', 'scripts'), 'run.sh', '#!/bin/sh\necho stale\n'); + + const catalog = new SkillCatalog(systemDir, userRoot); + const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext; + + const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx); + expect(result!.isError).toBe(false); + const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8'); + expect(copied).toContain('fresh'); + }); + it('does NOT prepend path for single-file skills', () => { const systemDir = makeTempDir(); const userRoot = makeTempDir(); @@ -417,6 +621,70 @@ describe('InstallSkill tool', () => { expect(fs.existsSync(path.join(targetDir, 'scripts', 'run.sh'))).toBe(true); }); + // Folder/frontmatter name mismatch guard: installing under a name that + // differs from the SKILL.md frontmatter `name` produces a skill that is + // listed under the frontmatter name but stored under the install name — + // undeletable/uneditable from the UI. InstallSkill must reject the mismatch. + it('rejects a sourcePath install when the frontmatter name differs from the install name', () => { + const sourceDir = makeSourceSkill('fm-name'); + const ctx = makeDirCtx(); + + const result = executeSkillTool('InstallSkill', { + sourcePath: sourceDir, + name: 'other-name', + scope: 'user', + }, ctx); + + expect(result!.isError).toBe(true); + expect(result!.output).toContain('fm-name'); + expect(result!.output).toContain('other-name'); + expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'other-name'))).toBe(false); + }); + + it('rejects a sourcePath install when SKILL.md has no valid frontmatter name', () => { + const skillDir = path.join(workspace, 'no-fm'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# just a heading\nno frontmatter here', 'utf-8'); + const ctx = makeDirCtx(); + + const result = executeSkillTool('InstallSkill', { + sourcePath: skillDir, + name: 'no-fm', + scope: 'user', + }, ctx); + + expect(result!.isError).toBe(true); + expect(result!.output).toContain('frontmatter'); + expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'no-fm'))).toBe(false); + }); + + it('rejects a content install when the frontmatter name differs from the install name', () => { + const ctx = makeDirCtx(); + const result = executeSkillTool('InstallSkill', { + name: 'param-name', + content: `---\nname: content-name\ndescription: mismatch\n---\n\nbody`, + scope: 'user', + }, ctx); + + expect(result!.isError).toBe(true); + expect(result!.output).toContain('content-name'); + expect(result!.output).toContain('param-name'); + expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'param-name'))).toBe(false); + }); + + it('rejects a content install when the content has no valid frontmatter name', () => { + const ctx = makeDirCtx(); + const result = executeSkillTool('InstallSkill', { + name: 'no-fm-content', + content: '# heading only\nno frontmatter', + scope: 'user', + }, ctx); + + expect(result!.isError).toBe(true); + expect(result!.output).toContain('frontmatter'); + expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'no-fm-content'))).toBe(false); + }); + it('resolves a workspace-relative sourcePath against the workspace root', () => { makeSourceSkill('rel-skill', { scripts: true }); const ctx = makeDirCtx(); @@ -521,6 +789,110 @@ describe('InstallSkill tool', () => { expect(result!.output).toContain('admin'); }); + // --- sourcePath 再アンカー --- + // エージェントは workspace のホスト上の絶対パスを知らないため、スペースID等から + // 「data/space/{id}/files/output/xxx」のようなフルパス風の相対パスを組み立てがち。 + // workspace 末尾セグメント(2つ以上)がパス中に現れたら、その続きを workspace + // 相対として再解決する。 + + /** workspace をスペース実配置と同じ `.../space/{id}/files` 形にした ctx を作る */ + function makeSpaceLikeWorkspace(): { spaceWs: string; spaceId: string } { + const base = makeTempDir(); + dirs.push(base); + const spaceId = 'c3fef194-44d7-4cd8-8dc0-b41a5f92a3f3'; + const spaceWs = path.join(base, 'data', 'workspaces', 'space', spaceId, 'files'); + fs.mkdirSync(spaceWs, { recursive: true }); + return { spaceWs, spaceId }; + } + + function makeCtxForWorkspace(ws: string, opts?: { role?: 'admin' | 'user' }): ToolContext { + const catalog = new SkillCatalog(systemDir, userRoot); + return { + workspacePath: ws, + editAllowed: true, + skillCatalog: catalog, + userId: 'user1', + userRole: opts?.role ?? 'user', + } as ToolContext; + } + + it('re-anchors a sourcePath that embeds the workspace tail (space layout)', () => { + // Given: スペース workspace 直下 output/skill-template にスキル一式 + const { spaceWs, spaceId } = makeSpaceLikeWorkspace(); + const skillDir = path.join(spaceWs, 'output', 'skill-template'); + fs.mkdirSync(path.join(skillDir, 'templates'), { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: reanchor-skill\ndescription: A test skill\n---\nbody', 'utf-8'); + fs.writeFileSync(path.join(skillDir, 'templates', 'a.html'), '', 'utf-8'); + + // When: 実障害と同形の「フルパス風」相対パスを渡す + const result = executeSkillTool('InstallSkill', { + sourcePath: `data/space/${spaceId}/files/output/skill-template`, + name: 'reanchor-skill', + scope: 'user', + }, makeCtxForWorkspace(spaceWs)); + + // Then: 再アンカーされてフォルダごとインストールされる + expect(result!.isError).toBe(false); + const targetDir = path.join(userRoot, 'user1', 'skills', 'reanchor-skill'); + expect(fs.existsSync(path.join(targetDir, 'SKILL.md'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'templates', 'a.html'))).toBe(true); + }); + + it('re-anchors an absolute-looking sourcePath with a wrong prefix', () => { + const { spaceWs, spaceId } = makeSpaceLikeWorkspace(); + const skillDir = path.join(spaceWs, 'output', 'my-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: reanchor-abs\ndescription: A test skill\n---\nbody', 'utf-8'); + + const result = executeSkillTool('InstallSkill', { + sourcePath: `/srv/app/data/space/${spaceId}/files/output/my-skill`, + name: 'reanchor-abs', + scope: 'user', + }, makeCtxForWorkspace(spaceWs)); + + expect(result!.isError).toBe(false); + expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'reanchor-abs', 'SKILL.md'))).toBe(true); + }); + + it('does not re-anchor on a single ambiguous segment match', () => { + // workspace 末尾の "files" 1 セグメントだけ一致しても再アンカーしない + const { spaceWs } = makeSpaceLikeWorkspace(); + const decoy = path.join(spaceWs, 'x'); + fs.mkdirSync(decoy, { recursive: true }); + fs.writeFileSync(path.join(decoy, 'SKILL.md'), '---\nname: t\ndescription: A test skill\n---\nbody', 'utf-8'); + + const result = executeSkillTool('InstallSkill', { + sourcePath: 'nope/files/x', + name: 'ambiguous', + scope: 'user', + }, makeCtxForWorkspace(spaceWs)); + + expect(result!.isError).toBe(true); + }); + + it('hints workspace-relative paths when sourcePath cannot be resolved', () => { + const ctx = makeDirCtx(); + const result = executeSkillTool('InstallSkill', { + sourcePath: 'no/such/dir', + name: 'missing', + scope: 'user', + }, ctx); + + expect(result!.isError).toBe(true); + expect(result!.output).toContain('workspace-relative'); + }); + + it('does not re-anchor a path containing ".."', () => { + const { spaceWs, spaceId } = makeSpaceLikeWorkspace(); + const result = executeSkillTool('InstallSkill', { + sourcePath: `data/space/${spaceId}/files/../../../../evil`, + name: 'evil-dots', + scope: 'user', + }, makeCtxForWorkspace(spaceWs)); + + expect(result!.isError).toBe(true); + }); + it('overwrites existing skill on reinstall', () => { const sourceDir = makeSourceSkill('overwrite-me', { scripts: true }); const ctx = makeDirCtx(); diff --git a/src/engine/tools/skills.ts b/src/engine/tools/skills.ts index 96518d4..e83503b 100644 --- a/src/engine/tools/skills.ts +++ b/src/engine/tools/skills.ts @@ -1,10 +1,26 @@ -import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync } from 'fs'; -import { join, isAbsolute, resolve } from 'path'; +import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync, readFileSync } from 'fs'; +import { createHash, randomUUID } from 'crypto'; +import { join, isAbsolute, resolve, relative } from 'path'; import type { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { VALID_SKILL_NAME } from '../skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../skills-scanner.js'; import { logger } from '../../logger.js'; +import matter from 'gray-matter'; + +/** + * Extract the frontmatter `name` from SKILL.md text ('' when missing or + * unparseable). The skill catalog lists skills by this name, so installs must + * keep it in sync with the folder name (see the mismatch guard in InstallSkill). + */ +function frontmatterNameOf(raw: string): string { + try { + const { data } = matter(raw); + return typeof data?.name === 'string' ? data.name : ''; + } catch { + return ''; + } +} // ── Injected deps (server.ts / worker.ts call setSkillToolDeps) ───────────── @@ -38,13 +54,40 @@ function skillFolderTarget(ctx: ToolContext): { root: string; leaf: string } { return { root: personalRoot, leaf: ctx.userId ?? 'local' }; } +/** + * `sourcePath` の中に workspace 実パスの末尾セグメント列(2 つ以上)が現れたら、 + * その直後からを workspace 相対パスとして返す。見つからなければ null。 + * 例: workspace=…/space/{id}/files, sourcePath="data/space/{id}/files/output/x" + * → "output/x"。1 セグメントだけの一致(例: "files")は誤アンカーしやすいので + * 対象外。".." を含むパスは再解釈しない(通常の解決に任せて拒否させる)。 + */ +function reanchorToWorkspace(workspacePath: string, sourcePath: string): string | null { + const wsSegs = resolve(workspacePath).split('/').filter(Boolean); + const srcSegs = sourcePath.split(/[\\/]+/).filter(s => s && s !== '.'); + if (srcSegs.includes('..')) return null; + for (let len = Math.min(wsSegs.length, srcSegs.length); len >= 2; len--) { + const tail = wsSegs.slice(wsSegs.length - len); + for (let i = 0; i + len <= srcSegs.length; i++) { + if (tail.every((seg, j) => seg === srcSegs[i + j])) { + const rest = srcSegs.slice(i + len); + return rest.length > 0 ? rest.join('/') : null; + } + } + } + return null; +} + // ── Skill materialization ─────────────────────────────────────────────────── // When the agent ReadSkill's a directory-based skill, copy its files into the // task workspace (`{workspace}/skills/{name}/`) so its scripts are usable in // every Bash sandbox mode (the skill store lives outside the workspace and is -// only bind-mounted in the bwrap path). The copy is rw and idempotent per task. +// only bind-mounted in the bwrap path). The copy is rw. A signature of the +// source dir is stored alongside the copy; when the source changes (e.g. the +// skill was updated by another task) the next ReadSkill replaces the copy — +// including any local edits. An unchanged source leaves the copy untouched. const SKILL_MATERIALIZE_MAX_BYTES = 50 * 1024 * 1024; // 50MB +const SKILL_SIGNATURE_SUFFIX = '.skill-src-signature'; /** Total size of `dir` (skipping symlinks), or null once it exceeds `cap`. */ function dirSizeCapped(dir: string, cap: number): number | null { @@ -69,28 +112,127 @@ function dirSizeCapped(dir: string, cap: number): number | null { interface MaterializeResult { ok: boolean; relPath: string; note?: string } -/** Copy a skill's source dir into `{workspace}/skills/{name}/` (idempotent). */ +/** + * Signature of a skill source dir: sha1 over each file's relative path and + * content bytes, symlinks skipped — the same file set `materializeSkill` + * copies. Content-based (not size/mtime) so a re-synced store with identical + * bytes does not count as a change. Source dirs are capped at 50MB and + * ReadSkill is infrequent, so hashing the bytes is cheap enough. + */ +function skillSourceSignature(srcDir: string): string { + const files: string[] = []; + const stack = [srcDir]; + while (stack.length > 0) { + const d = stack.pop()!; + let entries: string[]; + try { entries = readdirSync(d); } catch { continue; } + for (const e of entries) { + const p = join(d, e); + let st; + try { st = lstatSync(p); } catch { continue; } + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) { stack.push(p); continue; } + files.push(relative(srcDir, p)); + } + } + files.sort(); + const hash = createHash('sha1'); + for (const rel of files) { + // Length-prefix each field so path/content boundaries stay unambiguous. + hash.update(`${rel.length}:${rel}:`); + let content: Buffer; + try { content = readFileSync(join(srcDir, rel)); } catch { content = Buffer.from(''); } + hash.update(`${content.length}:`); + hash.update(content); + } + return hash.digest('hex'); +} + +/** + * Copy a skill's source dir into `{workspace}/skills/{name}/`. A stale copy + * (source changed since it was made, or made before signature tracking) is + * replaced wholesale; an up-to-date copy is left untouched. The copy is staged + * in a temp dir first so a failed refresh keeps the previous copy usable, and + * the signature lives in a sidecar (`skills/.{name}.skill-src-signature`) so + * it can never clobber a file the skill itself owns. Skill names are + * `[a-z0-9_-]` only, so the dot-prefixed sidecar/tmp names cannot collide + * with a real skill dir. + */ export function materializeSkill(srcDir: string, workspacePath: string, skillName: string): MaterializeResult { const relPath = `skills/${skillName}`; - const dest = join(workspacePath, 'skills', skillName); - if (existsSync(dest)) return { ok: true, relPath }; // already materialized this task + const skillsRoot = join(workspacePath, 'skills'); + const dest = join(skillsRoot, skillName); + const sigPath = join(skillsRoot, `.${skillName}${SKILL_SIGNATURE_SUFFIX}`); + // Cap check first: the signature reads every source byte, so an oversized + // dir must be rejected before it gets loaded into memory. if (dirSizeCapped(srcDir, SKILL_MATERIALIZE_MAX_BYTES) === null) { return { ok: false, relPath, note: 'skill exceeds 50MB copy limit' }; } + const signature = skillSourceSignature(srcDir); + if (existsSync(dest)) { + let existing: string | null = null; + try { existing = readFileSync(sigPath, 'utf-8').trim(); } catch { /* legacy copy without signature → refresh */ } + if (existing === signature) return { ok: true, relPath }; // copy is up to date + } + // Per-call unique tmp: within one process ReadSkill runs synchronously, but + // another process sharing the workspace (dev + prod, multi-instance) may be + // materializing the same skill concurrently — never touch its tmp. + const tmp = join(skillsRoot, `.${skillName}.tmp-${randomUUID()}`); try { - mkdirSync(join(workspacePath, 'skills'), { recursive: true }); - cpSync(srcDir, dest, { + mkdirSync(skillsRoot, { recursive: true }); + sweepStaleTmpDirs(skillsRoot, skillName); + cpSync(srcDir, tmp, { recursive: true, dereference: false, // Skip symlinks so a link inside the skill cannot point outside the workspace. filter: (src) => { try { return !lstatSync(src).isSymbolicLink(); } catch { return false; } }, }); + // Swap in only after the whole copy succeeded. The old copy is moved + // aside (not deleted) so a failed swap can put it back. + const backup = join(skillsRoot, `.${skillName}.tmp-bak-${randomUUID()}`); + let haveBackup = false; + if (existsSync(dest)) { renameSync(dest, backup); haveBackup = true; } + try { + renameSync(tmp, dest); + } catch (e) { + if (haveBackup && !existsSync(dest)) { + try { renameSync(backup, dest); } catch { /* leave the backup for the sweep */ } + } + throw e; + } + if (haveBackup) { try { rmSync(backup, { recursive: true, force: true }); } catch { /* sweep will get it */ } } + writeFileSync(sigPath, `${signature}\n`, 'utf-8'); return { ok: true, relPath }; } catch (e) { + try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ } + // A concurrent materialize may have won the swap — then the copy is fine. + try { + if (existsSync(dest) && readFileSync(sigPath, 'utf-8').trim() === signature) { + return { ok: true, relPath }; + } + } catch { /* fall through to the failure result */ } return { ok: false, relPath, note: `copy failed: ${(e as Error).message}` }; } } +const SKILL_TMP_STALE_MS = 15 * 60_000; + +/** Remove `.{name}.tmp-*` leftovers from crashed runs; keep recent ones (a copy may be in flight). */ +function sweepStaleTmpDirs(skillsRoot: string, skillName: string): void { + let entries: string[]; + try { entries = readdirSync(skillsRoot); } catch { return; } + const prefix = `.${skillName}.tmp-`; + for (const e of entries) { + if (!e.startsWith(prefix)) continue; + const p = join(skillsRoot, e); + try { + if (Date.now() - lstatSync(p).mtimeMs > SKILL_TMP_STALE_MS) { + rmSync(p, { recursive: true, force: true }); + } + } catch { /* best effort */ } + } +} + // --------------------------------------------------------------------------- // Tool definitions // --------------------------------------------------------------------------- @@ -103,9 +245,9 @@ export const TOOL_DEFS: Record = { parameters: { type: 'object', properties: { - name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)' }, + name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)。SKILL.md frontmatter の name: と完全一致必須' }, content: { type: 'string', description: 'SKILL.md の全文 (YAML frontmatter + 本文)。通常はこちらを使用' }, - sourcePath: { type: 'string', description: 'workspace 内のスキルディレクトリの絶対パス (SKILL.md + scripts/ 等を含む場合のみ)。workspace 外のパスは拒否される' }, + sourcePath: { type: 'string', description: 'workspace 相対のスキルディレクトリパス(例: "output/my-skill"。SKILL.md + scripts/ 等を含むフォルダごと登録する場合のみ)。workspace 外のパスは拒否される' }, scope: { type: 'string', enum: ['system', 'user'], description: 'system=全ユーザー共有 (admin only), user=個人' }, }, required: ['name', 'scope'], @@ -195,8 +337,13 @@ function executeInstallSkill( if (typeof sourcePath !== 'string') { return { output: 'InstallSkill: "sourcePath" must be a string', isError: true }; } - let realSource: string; let realWorkspace: string; + try { + realWorkspace = realpathSync(ctx.workspacePath); + } catch (e) { + return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true }; + } + let realSource: string; try { // Resolve relative paths against the workspace root, mirroring // resolveAndGuard() used by Read/Write/Edit. Without this, a natural @@ -206,9 +353,18 @@ function executeInstallSkill( // friendly result instead of crashing the task. const absSource = isAbsolute(sourcePath) ? sourcePath : resolve(ctx.workspacePath, sourcePath); realSource = realpathSync(absSource); - realWorkspace = realpathSync(ctx.workspacePath); } catch (e) { - return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true }; + // Agents don't know the workspace's host-side absolute path, so they + // often reconstruct a plausible-looking one (e.g. "data/space/{id}/files/ + // output/x"). If the given path embeds the workspace's trailing segments, + // re-anchor: treat everything after that run as workspace-relative. + const reanchored = reanchorToWorkspace(ctx.workspacePath, sourcePath); + try { + if (!reanchored) throw new Error('no re-anchor candidate'); + realSource = realpathSync(resolve(ctx.workspacePath, reanchored)); + } catch { + return { output: `InstallSkill: sourcePath does not exist or is not accessible. Specify a workspace-relative path (e.g. "output/my-skill"), or use "content" to pass SKILL.md text directly.`, isError: true }; + } } if (!realSource.startsWith(realWorkspace + '/')) { return { output: 'InstallSkill: sourcePath must be inside the task workspace. Use "content" parameter to pass SKILL.md text directly.', isError: true }; @@ -217,6 +373,22 @@ function executeInstallSkill( return { output: `InstallSkill: SKILL.md not found in ${sourcePath}`, isError: true }; } + // The catalog lists skills by their SKILL.md frontmatter `name`. If it + // differs from the install name the skill gets stored under one name but + // listed under another — the UI can then never edit or delete it. + let srcFmName = ''; + try { + srcFmName = frontmatterNameOf(readFileSync(join(realSource, 'SKILL.md'), 'utf-8')); + } catch { + srcFmName = ''; + } + if (!srcFmName || !VALID_SKILL_NAME.test(srcFmName)) { + return { output: 'InstallSkill: SKILL.md must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true }; + } + if (srcFmName !== skillName) { + return { output: `InstallSkill: SKILL.md frontmatter name "${srcFmName}" does not match the install name "${skillName}". Pass name: "${srcFmName}" (or fix the frontmatter) so they match.`, isError: true }; + } + const stats = getDirStats(realSource); if (stats.fileCount > MAX_FILE_COUNT) { return { output: `InstallSkill: directory contains ${stats.fileCount} files (max ${MAX_FILE_COUNT})`, isError: true }; @@ -269,6 +441,16 @@ function executeInstallSkill( return { output: `InstallSkill: content exceeds 64 KB limit`, isError: true }; } + // Same mismatch guard as sourcePath mode: the frontmatter name must exist + // and match the install name, or the skill becomes unmanageable from the UI. + const contentFmName = frontmatterNameOf(content!); + if (!contentFmName || !VALID_SKILL_NAME.test(contentFmName)) { + return { output: 'InstallSkill: content must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true }; + } + if (contentFmName !== skillName) { + return { output: `InstallSkill: content frontmatter name "${contentFmName}" does not match the install name "${skillName}". Pass name: "${contentFmName}" (or fix the frontmatter) so they match.`, isError: true }; + } + const findings = scanSkillContent(content!); const severity = maxSeverity(findings); if (severity === 'high') { diff --git a/src/engine/tools/ssh-console-space.test.ts b/src/engine/tools/ssh-console-space.test.ts index 5a4192b..9bd8cd9 100644 --- a/src/engine/tools/ssh-console-space.test.ts +++ b/src/engine/tools/ssh-console-space.test.ts @@ -26,6 +26,7 @@ import { createGrantsRepo } from '../../ssh/grants-repo.js'; import { createAuditRepo } from '../../ssh/audit-repo.js'; import { createAbuseRepo } from '../../ssh/abuse-repo.js'; import { createAccessResolver } from '../../ssh/access.js'; +import { SessionRegistry } from '../../ssh/console-registry.js'; import { SSH_DEFAULTS } from '../../ssh/config.js'; import { preflight, setSshSubsystem, type SshSubsystem } from './ssh.js'; import { openConsoleSession } from './ssh-console.js'; @@ -69,18 +70,17 @@ function makeSubsystem(db: Database.Database): { sub: SshSubsystem; conn: SshCon maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => undefined, exit: () => undefined }, // Enable the console (disabled by default) so the agent-tool wrapper reaches preflight. config: { ...SSH_DEFAULTS, console: { ...SSH_DEFAULTS.console, enabled: true } }, - sessionRegistry: { - register: () => undefined, - get: () => null, - listAll: () => [], - listForConnection: () => [], - closeForTask: async () => undefined, - enforceCap: () => [], - sweep: async () => undefined, - startSweepTimer: () => undefined, - stopSweepTimer: () => undefined, - shutdown: async () => undefined, - }, + // REAL registry, not a stub: every test here fails preflight or dies at + // the openShellChannel sentinel, so no session is ever registered and the + // registry stays empty (no sweep timer is started without an explicit + // startSweepTimer call). A previous hand-rolled stub silently broke when + // multi-session (#732) added listForTask/closeSession to the registry API; + // the real class cannot drift like that. + sessionRegistry: new SessionRegistry({ + idleTimeoutMs: 60_000, + maxSessionDurationMs: 600_000, + maxSessionsPerConnection: 2, + }), // A cleared-preflight session reaches this and fails here (open_shell_failed). openShellChannel: async () => { throw new Error(SHELL_SENTINEL); diff --git a/src/engine/tools/task-conversation.test.ts b/src/engine/tools/task-conversation.test.ts index 60bc038..6d71119 100644 --- a/src/engine/tools/task-conversation.test.ts +++ b/src/engine/tools/task-conversation.test.ts @@ -143,6 +143,51 @@ describe('SearchTaskConversation', () => { }); }); +describe('SearchTaskConversation — AND search (space-separated tokens)', () => { + it('matches when all tokens appear even if non-contiguous', async () => { + // comment:10 = 'Please keep the existing auth flow unchanged.' + // 'auth unchanged' is not a contiguous substring, but both tokens appear. + const ctx = makeCtx(makeIO(commentsA)); + const res = await executeTool('SearchTaskConversation', { query: 'auth unchanged', source: 'comments' }, ctx); + expect(res!.isError).toBe(false); + expect(res!.output).toContain('comment:10'); + }); + + it('does not match when tokens are split across different entries (AND, not OR)', async () => { + // 'login' appears only in comment:11, 'components' only in comment:12. + const ctx = makeCtx(makeIO(commentsA)); + const res = await executeTool('SearchTaskConversation', { query: 'login components', source: 'comments' }, ctx); + expect(res!.output).not.toContain('comment:11'); + expect(res!.output).not.toContain('comment:12'); + expect(res!.output).toContain('見つかりませんでした'); + }); + + it('applies AND matching to the transcript source too', async () => { + const tp = writeTranscript('transcript.jsonl', [ + { role: 'user', content: 'Constraint: never touch the payment code.' }, + { role: 'assistant', content: 'I refactored the payment module docs.' }, + ]); + const ctx = makeCtx(makeIO([], tp)); + const res = await executeTool('SearchTaskConversation', { query: 'payment constraint', source: 'transcript' }, ctx); + expect(res!.output).toContain('transcript:0'); + expect(res!.output).not.toContain('transcript:1'); + }); + + it('treats a full-width (ideographic) space as a token separator', async () => { + const ctx = makeCtx(makeIO(commentsA)); + const res = await executeTool('SearchTaskConversation', { query: 'auth unchanged', source: 'comments' }, ctx); + expect(res!.output).toContain('comment:10'); + }); + + it('centers the excerpt on the first token hit when the full query never appears contiguously', async () => { + const body = 'x'.repeat(300) + ' alpha middle beta ' + 'y'.repeat(300); + const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'comment', body, createdAt: '2026-06-30T10:00:00Z' }])); + const res = await executeTool('SearchTaskConversation', { query: 'alpha beta' }, ctx); + expect(res!.output).toContain('comment:1'); + expect(res!.output).toContain('alpha'); // excerpt must show the hit, not the head of the body + }); +}); + // P0/P1 (review): the REAL scoping guarantee comes from // repo.makeTaskConversationIO(taskId) → listLocalTaskComments(taskId) WHERE // task_id = ?. Exercise it end-to-end with a real temp-DB Repository and two @@ -202,3 +247,25 @@ describe('ReadTaskConversation', () => { expect(res!.output.length).toBeGreaterThan(0); }); }); + +describe('sub-task transcript-only recall', () => { + it('searches the transcript while comments stay empty', async () => { + const tp = writeTranscript('transcript.jsonl', [ + { role: 'user', content: 'Sub-task brief: summarize the quarterly churn drivers.' }, + { role: 'assistant', content: 'Noted churn driver: onboarding friction.' }, + ]); + const ctx = makeCtx(makeIO([], tp)); // transcript-only shape (no comments) + const res = await executeTool('SearchTaskConversation', { query: 'churn', source: 'both' }, ctx); + expect(res!.isError).toBe(false); + expect(res!.output).toContain('transcript:0'); + expect(res!.output).toContain('transcript:1'); + }); + + it('degrades gracefully when a comment ref is read but no comments exist', async () => { + const tp = writeTranscript('transcript.jsonl', [{ role: 'user', content: 'hello' }]); + const ctx = makeCtx(makeIO([], tp)); + const res = await executeTool('ReadTaskConversation', { ref: 'comment:1' }, ctx); + expect(res!.isError).toBe(true); + expect(res!.output).toContain('見つかりません'); + }); +}); diff --git a/src/engine/tools/task-conversation.ts b/src/engine/tools/task-conversation.ts index 2e51e22..1863cbe 100644 --- a/src/engine/tools/task-conversation.ts +++ b/src/engine/tools/task-conversation.ts @@ -12,16 +12,20 @@ * * Scoping is enforced by the bound `ctx.taskConversation` IO: comments come from * `listLocalTaskComments(taskId)` (WHERE task_id = ?), and the transcript path is - * bound by the worker ONLY to a per-task `runtimeDir` — never the space-shared - * `workspacePath/logs` fallback — so a tool call can never read another task's - * logs. When the transcript path is unset the transcript source yields nothing - * (comments still work); when the whole IO is unset (subtasks / gitea-issue runs) - * the tools degrade to a friendly non-error message. + * bound by the worker to a location a tool call can never use to read another + * task's logs. For a top-level local task that is the per-task `runtimeDir` + * (never the space-shared `workspacePath/logs` fallback). A sub-task run instead + * gets a transcript-only IO (no comments — see `makeTranscriptOnlyConversationIO`) + * bound to the sub-task's OWN isolated workspace transcript. When the transcript + * path is unset the transcript source yields nothing (comments still work); when + * the whole IO is unset (gitea-issue runs) the tools degrade to a friendly + * non-error message. */ import { existsSync, readFileSync } from 'node:fs'; import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult, TaskConversationComment } from './core.js'; +import { excerpt, tokenize } from './text-excerpt.js'; const SEARCH_DEF: ToolDef = { type: 'function', @@ -32,7 +36,7 @@ const SEARCH_DEF: ToolDef = { parameters: { type: 'object', properties: { - query: { type: 'string', description: '検索キーワード (部分一致・大文字小文字無視)。' }, + query: { type: 'string', description: '検索キーワード。スペース区切りで複数指定すると AND 検索 (すべての語を含む発言にヒット)。部分一致・大文字小文字無視。' }, source: { type: 'string', enum: ['comments', 'transcript', 'both'], description: '検索対象。既定 both。' }, author: { type: 'string', enum: ['user', 'agent', 'system'], description: '発言者で絞り込む。' }, kind: { type: 'string', description: 'コメント種別で絞り込む (request/comment/interjection/result/ask/progress/handoff)。transcript には適用されない。' }, @@ -105,23 +109,6 @@ function loadTranscript(path: string | undefined): TranscriptEntry[] { return out; } -function collapse(s: string): string { - return s.replace(/\s+/g, ' ').trim(); -} - -/** Compact excerpt centered on the first query hit, bounded to `max` chars. */ -function excerpt(body: string, query: string, max: number): string { - const flat = collapse(body); - if (flat.length <= max && !query) return flat; - const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0; - if (idx < 0 || flat.length <= max) { - return flat.length <= max ? flat : flat.slice(0, max - 1) + '…'; - } - const start = Math.max(0, idx - 40); - const end = Math.min(flat.length, start + max); - return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : ''); -} - function clampLimit(v: unknown, def: number, max: number): number { const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def; return Math.min(Math.max(n, 1), max); @@ -144,7 +131,11 @@ async function search(input: Record, ctx: ToolContext): Promise const author = input['author'] as Author | undefined; const kind = typeof input['kind'] === 'string' ? (input['kind'] as string) : undefined; const limit = clampLimit(input['limit'], 10, 50); - const q = query.toLowerCase(); + const tokens = tokenize(query); + const matches = (body: string): boolean => { + const lower = body.toLowerCase(); + return tokens.every((t) => lower.includes(t)); + }; type Hit = { ref: string; header: string; body: string }; const hits: Hit[] = []; @@ -154,7 +145,7 @@ async function search(input: Record, ctx: ToolContext): Promise for (const c of comments) { if (author && c.author !== author) continue; if (kind && c.kind !== kind) continue; - if (!c.body.toLowerCase().includes(q)) continue; + if (!matches(c.body)) continue; hits.push({ ref: `comment:${c.id}`, header: `${c.author}/${c.kind} ${c.createdAt}`, body: c.body }); } } @@ -165,7 +156,7 @@ async function search(input: Record, ctx: ToolContext): Promise if (!kind) { for (const e of loadTranscript(io.transcriptPath)) { if (author && e.author !== author) continue; - if (!e.content.toLowerCase().includes(q)) continue; + if (!matches(e.content)) continue; hits.push({ ref: `transcript:${e.index}`, header: e.author, body: e.content }); } } @@ -179,7 +170,7 @@ async function search(input: Record, ctx: ToolContext): Promise const lines = [`## Conversation Search Results (query: "${query}", ${hits.length} hit(s)${hits.length > limit ? `, showing ${limit}` : ''})`, '']; for (const h of shown) { lines.push(`- ${h.ref} ${h.header}`); - lines.push(` ${excerpt(h.body, query, EXCERPT_MAX)}`); + lines.push(` ${excerpt(h.body, tokens, EXCERPT_MAX)}`); lines.push(''); } lines.push('前後の文脈は ReadTaskConversation({ ref }) で確認できます。'); @@ -209,7 +200,7 @@ async function read(input: Record, ctx: ToolContext): Promise, ctx: ToolContext): Promise= 0 && (idx < 0 || i < idx)) idx = i; + } + if (idx < 0) return flat.slice(0, max - 1) + '…'; + const start = Math.max(0, idx - 40); + const end = Math.min(flat.length, start + max); + return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : ''); +} diff --git a/src/engine/tools/tool-categories.ts b/src/engine/tools/tool-categories.ts index 92ee72a..ec3603b 100644 --- a/src/engine/tools/tool-categories.ts +++ b/src/engine/tools/tool-categories.ts @@ -45,6 +45,7 @@ export const META_TOOLS = new Set([ 'ListSkills', 'InstallSkill', 'RequestTool', + 'RequestPackage', ]); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/engine/tools/workspace-task-search.test.ts b/src/engine/tools/workspace-task-search.test.ts index dcf0b9d..b79fcdf 100644 --- a/src/engine/tools/workspace-task-search.test.ts +++ b/src/engine/tools/workspace-task-search.test.ts @@ -87,6 +87,23 @@ describe('SearchWorkspaceTasks tool', () => { expect(res!.output).toMatch(/見つからない|スコープ外/); }); + it('centers the excerpt on the first token hit for multi-word (AND) queries', async () => { + // FTS5 matches tokens non-contiguously, so the excerpt must center on the + // first token occurrence — not require the whole query as one substring. + const text = 'x'.repeat(300) + ' 認証エラー が発生、その後 再ログイン で解消 ' + 'y'.repeat(300); + const io = { + fts5Available: true, + search: async () => [{ taskId: 1, taskTitle: 'T', commentId: 2, author: 'agent', kind: 'result', createdAt: 'd', text }], + around: async () => null, + }; + const res = await executeTool('SearchWorkspaceTasks', { query: '認証エラー 再ログイン' }, ctxWith(io)); + expect(res!.isError).toBe(false); + // The excerpt line (2-space indent) must show the hit, not the head of the body. + const excerptLine = res!.output.split('\n').find((l) => l.startsWith(' ') && !l.startsWith(' ')); + expect(excerptLine).toBeDefined(); + expect(excerptLine!).toContain('認証エラー'); + }); + it('context>0 attaches neighbor comments under each hit', async () => { let aroundCalledWith: { id: number; before: number; after: number } | null = null; const io = { diff --git a/src/engine/tools/workspace-task-search.ts b/src/engine/tools/workspace-task-search.ts index cbabd11..f4fa3e7 100644 --- a/src/engine/tools/workspace-task-search.ts +++ b/src/engine/tools/workspace-task-search.ts @@ -18,6 +18,7 @@ import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; +import { excerpt, tokenize } from './text-excerpt.js'; const SEARCH_DEF: ToolDef = { type: 'function', @@ -52,23 +53,6 @@ const FTS5_UNAVAILABLE_MSG = const EXCERPT_MAX = 200; -function collapse(s: string): string { - return s.replace(/\s+/g, ' ').trim(); -} - -/** Compact excerpt centered on the first query hit, bounded to `max` chars. */ -function excerpt(body: string, query: string, max: number): string { - const flat = collapse(body); - if (flat.length <= max && !query) return flat; - const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0; - if (idx < 0 || flat.length <= max) { - return flat.length <= max ? flat : flat.slice(0, max - 1) + '…'; - } - const start = Math.max(0, idx - 40); - const end = Math.min(flat.length, start + max); - return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : ''); -} - function clampLimit(v: unknown, def: number, max: number): number { const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def; return Math.min(Math.max(n, 1), max); @@ -125,14 +109,14 @@ async function search(input: Record, ctx: ToolContext): Promise const lines = [`## Workspace Task Search Results (query: "${query}", ${hits.length} hit(s))`, '']; for (const h of hits) { lines.push(`- task:${h.taskId} "${h.taskTitle}" / comment:${h.commentId} ${h.author}/${h.kind} ${h.createdAt}`); - lines.push(` ${excerpt(h.text, query!, EXCERPT_MAX)}`); + lines.push(` ${excerpt(h.text, tokenize(query!), EXCERPT_MAX)}`); if (context > 0) { const around = await io.around(h.commentId, context, context); if (around) { for (const c of around.comments) { const marker = c.isCenter ? ' ←' : ''; lines.push(` - comment:${c.commentId} ${c.author}/${c.kind} ${c.createdAt}${marker}`); - lines.push(` ${excerpt(c.body, '', EXCERPT_MAX)}`); + lines.push(` ${excerpt(c.body, [], EXCERPT_MAX)}`); } } } @@ -159,7 +143,7 @@ async function searchAroundRef(ref: string, context: number, io: NonNullable void; const DEFAULT_CONTEXT_LIMIT_TOKENS = 32_000; const DEFAULT_PROMPT_GUARD_RATIO = 0.8; -function estimateRequestTokens(messages: Message[], tools?: ToolDef[]): number { - const messageTokens = messages.reduce((total, message) => total + estimateMessageTokens(message), 0); - const toolTokens = tools && tools.length > 0 ? estimateToolsTokens(tools) : 0; - return messageTokens + toolTokens + 128; -} +// The estimate MUST stay byte-identical to what the agent-loop prompt guard +// computes (estimateRequestTokens in token-estimate.ts). A drift between the +// two creates a band of prompt sizes the guard passes but this preflight +// blocks — the error-recovery path then finds nothing to shrink and the loop +// resends the identical request until maxIterations. function contentChars(message: Message): number { if (typeof message.content === 'string') return message.content.length; diff --git a/src/progress/delegate-runs.test.ts b/src/progress/delegate-runs.test.ts index 04b454c..d0c0fc6 100644 --- a/src/progress/delegate-runs.test.ts +++ b/src/progress/delegate-runs.test.ts @@ -61,4 +61,127 @@ describe('reconstructDelegateRuns', () => { it('delegate イベントが無ければ空配列', () => { expect(reconstructDelegateRuns([ev({ seq: 1, kind: 'tool_call', payload: {} })])).toEqual([]); }); + + it('resultPreview がある場合に復元される', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R3', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'delegate_complete', payload: { delegateRunId: 'R3', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success', resultPreview: 'Success result preview' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].resultPreview).toBe('Success result preview'); + }); + + it('resultPreview がない場合に null になる', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R4', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'delegate_complete', payload: { delegateRunId: 'R4', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].resultPreview).toBeNull(); + }); + + it('llm_call_end イベントをトークン集計:prompt 100 + completion 50 = 150', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R5', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'llm_call_end', correlationId: 'R5', payload: { promptTokens: 100, completionTokens: 50 } }), + ev({ seq: 3, kind: 'delegate_complete', payload: { delegateRunId: 'R5', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].totalTokens).toBe(150); + }); + + it('複数 llm_call_end を累積:100+50 + 200+100 = 450', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R6', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'llm_call_end', correlationId: 'R6', payload: { promptTokens: 100, completionTokens: 50 } }), + ev({ seq: 3, kind: 'llm_call_end', correlationId: 'R6', payload: { promptTokens: 200, completionTokens: 100 } }), + ev({ seq: 4, kind: 'delegate_complete', payload: { delegateRunId: 'R6', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].totalTokens).toBe(450); + }); + + it('llm_call_end がトークン欠落のときは 0 として扱う', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R7', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'llm_call_end', correlationId: 'R7', payload: { /* no tokens */ } }), + ev({ seq: 3, kind: 'delegate_complete', payload: { delegateRunId: 'R7', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].totalTokens).toBe(0); + }); + + it('llm_call_end がなければ totalTokens = 0', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R8', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'R8', payload: { tool: 'WebFetch' } }), + ev({ seq: 3, kind: 'delegate_complete', payload: { delegateRunId: 'R8', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].totalTokens).toBe(0); + }); + + it('toolSummary: Write ×2(同一 file_path)+ WebFetch ×1 → [Write:2, WebFetch:1](初出順)', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R9', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'R9', payload: { tool: 'Write', args: { file_path: 'output.txt' } } }), + ev({ seq: 3, kind: 'tool_call', correlationId: 'R9', payload: { tool: 'WebFetch' } }), + ev({ seq: 4, kind: 'tool_call', correlationId: 'R9', payload: { tool: 'Write', args: { file_path: 'output.txt' } } }), + ev({ seq: 5, kind: 'delegate_complete', payload: { delegateRunId: 'R9', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].toolSummary).toEqual([ + { tool: 'Write', count: 2 }, + { tool: 'WebFetch', count: 1 }, + ]); + }); + + it('filesChanged: Write と Edit の file_path を重複除去して記録', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R10', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'R10', payload: { tool: 'Write', args: { file_path: 'file1.txt' } } }), + ev({ seq: 3, kind: 'tool_call', correlationId: 'R10', payload: { tool: 'Edit', args: { file_path: 'file2.txt' } } }), + ev({ seq: 4, kind: 'tool_call', correlationId: 'R10', payload: { tool: 'Write', args: { file_path: 'file1.txt' } } }), + ev({ seq: 5, kind: 'delegate_complete', payload: { delegateRunId: 'R10', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].filesChanged).toEqual(['file1.txt', 'file2.txt']); + }); + + it('filesChanged: Read など他ツールの file_path は入らない', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R11', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'R11', payload: { tool: 'Read', args: { file_path: 'input.txt' } } }), + ev({ seq: 3, kind: 'tool_call', correlationId: 'R11', payload: { tool: 'Write', args: { file_path: 'output.txt' } } }), + ev({ seq: 4, kind: 'delegate_complete', payload: { delegateRunId: 'R11', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].filesChanged).toEqual(['output.txt']); + }); + + it('tool_call がない run では toolSummary と filesChanged が空配列', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R12', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'llm_call_end', correlationId: 'R12', payload: { promptTokens: 100, completionTokens: 50 } }), + ev({ seq: 3, kind: 'delegate_complete', payload: { delegateRunId: 'R12', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].toolSummary).toEqual([]); + expect(runs[0].filesChanged).toEqual([]); + }); + + it('tool_call に args がなくてもクラッシュしない', () => { + const events = [ + ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R13', parentRunId: null, description: 'test', depth: 1 } }), + ev({ seq: 2, kind: 'tool_call', correlationId: 'R13', payload: { tool: 'WebFetch' } }), + ev({ seq: 3, kind: 'tool_call', correlationId: 'R13', payload: { tool: 'Write' } }), + ev({ seq: 4, kind: 'delegate_complete', payload: { delegateRunId: 'R13', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }), + ]; + const runs = reconstructDelegateRuns(events); + expect(runs[0].toolSummary).toEqual([ + { tool: 'WebFetch', count: 1 }, + { tool: 'Write', count: 1 }, + ]); + expect(runs[0].filesChanged).toEqual([]); + }); }); diff --git a/src/progress/delegate-runs.ts b/src/progress/delegate-runs.ts index 348c181..0fa89a9 100644 --- a/src/progress/delegate-runs.ts +++ b/src/progress/delegate-runs.ts @@ -15,6 +15,10 @@ export interface DelegateRun { endTs: string | null; eventCount: number; toolCalls: number; + resultPreview: string | null; + totalTokens: number; + toolSummary: Array<{ tool: string; count: number }>; + filesChanged: string[]; } function payloadOf(e: EventBase): Record { @@ -42,6 +46,10 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] { endTs: null, eventCount: 0, toolCalls: 0, + resultPreview: null, + totalTokens: 0, + toolSummary: [], + filesChanged: [], }); order.push(id); } @@ -55,6 +63,7 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] { run.endTs = e.ts; const s = String(p.status ?? ''); run.status = (s === 'success' || s === 'aborted' || s === 'needs_user_input') ? s : 'running'; + run.resultPreview = typeof p.resultPreview === 'string' ? p.resultPreview : null; } continue; } @@ -63,7 +72,34 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] { if (e.correlationId && byId.has(e.correlationId)) { const run = byId.get(e.correlationId)!; run.eventCount++; - if (e.kind === 'tool_call') run.toolCalls++; + if (e.kind === 'tool_call') { + run.toolCalls++; + const p = payloadOf(e); + const tool = typeof p.tool === 'string' ? p.tool : null; + if (tool) { + // toolSummary に追加:既存エントリがあれば count を +1、なければ末尾に追加 + const existing = run.toolSummary.find((t) => t.tool === tool); + if (existing) { + existing.count++; + } else { + run.toolSummary.push({ tool, count: 1 }); + } + // filesChanged に追加:Write/Edit なら args.file_path を抽出 + if ((tool === 'Write' || tool === 'Edit') && typeof p.args === 'object' && p.args !== null) { + const args = p.args as Record; + const filePath = typeof args.file_path === 'string' ? args.file_path : null; + if (filePath && !run.filesChanged.includes(filePath)) { + run.filesChanged.push(filePath); + } + } + } + } + if (e.kind === 'llm_call_end') { + const p = payloadOf(e); + const promptTokens = typeof p.promptTokens === 'number' ? p.promptTokens : 0; + const completionTokens = typeof p.completionTokens === 'number' ? p.completionTokens : 0; + run.totalTokens += promptTokens + completionTokens; + } } } diff --git a/src/worker.execute-job-gates.test.ts b/src/worker.execute-job-gates.test.ts new file mode 100644 index 0000000..cdec282 --- /dev/null +++ b/src/worker.execute-job-gates.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Worker } from './worker.js'; +import { userPiecesDir } from './user-folder/paths.js'; +import type { AppConfig } from './config.js'; +import type { Job } from './db/repository.js'; + +/** + * executeJob の pre-flight ゲート(切り出しリファクタ前のピン留めテスト)。 + * + * 対象: + * - MCP 認証ゲート: piece.required_mcp のトークンが無い場合、 + * status='waiting_human' + wait_reason='mcp_auth_required' で park する。 + * wait_reason の永続化が漏れるとジョブが沈黙して永久停止する既知の + * 失敗モードがあるため、パッチ内容を厳密に固定する。 + * - Model-mismatch requeue ゲート: piece が pin したモデルをこの worker が + * サーブできない場合、status='queued' + workerId=null で再キューする。 + * + * どちらも executeJob を実際に走らせ、リポジトリモックへの書き込みを検証する + * (Given-When-Then)。 + */ + +function makeConfig(overrides: Partial): AppConfig { + return { + provider: { + model: 'test-model', + workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1' }], + }, + worktreeDir: '/tmp/worker-gate-test', + concurrency: 1, + maxMovements: 30, + retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] }, + ask: { maxPerJob: 2 }, + subtasks: { maxDepth: 2 }, + tools: { + searxngUrl: 'http://localhost:8080', + visionModel: 'vision', + visionTimeout: 60, + visionMaxTokens: 1024, + webfetchTimeout: 30, + websearchTimeout: 15, + webfetchAllowedHosts: [], + }, + ...overrides, + } as AppConfig; +} + +function makeLocalTaskJob(overrides: Partial): Job { + return { + id: 'job-gate-1', + repo: 'local/task-77', + issueNumber: 77, + prNumber: null, + status: 'running', + pieceName: 'pin-gate-piece', + requiredRole: 'auto', + requiredProfile: 'auto', + currentMovement: null, + instruction: 'do something', + branchName: null, + worktreePath: null, + attempt: 1, + maxAttempts: 3, + nextRetryAt: null, + errorSummary: null, + resumeMovement: null, + askCount: 0, + workerId: 'worker-1', + parentJobId: null, + subtaskDepth: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as unknown as Job; +} + +function makeRepoMock() { + const updateJobCalls: Array<[string, Record]> = []; + const repo = { + lockIssue: vi.fn().mockResolvedValue(true), + unlockIssue: vi.fn().mockResolvedValue(undefined), + addAuditLog: vi.fn().mockResolvedValue(undefined), + updateWorkerNodeHealth: vi.fn().mockResolvedValue(undefined), + updateJob: vi.fn().mockImplementation((id: string, patch: Record) => { + updateJobCalls.push([id, patch]); + return Promise.resolve(undefined); + }), + getLocalTask: vi.fn().mockResolvedValue(null), + updateLocalTask: vi.fn().mockResolvedValue(undefined), + addLocalTaskComment: vi.fn().mockResolvedValue(undefined), + }; + return { repo, updateJobCalls }; +} + +/** Given: userFolderRoot 配下の local オーナー custom piece ディレクトリに piece を配置 */ +function writeCustomPiece(userFolderRoot: string, name: string, extraTopLevel: string): void { + const dir = userPiecesDir(userFolderRoot, 'local'); + mkdirSync(dir, { recursive: true }); + const yaml = [ + `name: ${name}`, + 'description: gate pinning test piece', + 'initial_movement: respond', + extraTopLevel, + 'movements:', + ' - name: respond', + ' persona: assistant', + ' instruction: answer the user', + ' default_next: COMPLETE', + ' rules: []', + '', + ].join('\n'); + writeFileSync(join(dir, `${name}.yaml`), yaml, 'utf-8'); +} + +describe('executeJob pre-flight gates (behaviour pinning)', () => { + it('parks the job as waiting_human with wait_reason=mcp_auth_required when a required MCP token is missing', async () => { + // Given: required_mcp を持つ custom piece と、MCP トークンを一切持たない worker + const root = mkdtempSync(join(tmpdir(), 'worker-gate-mcp-')); + const userFolderRoot = join(root, 'users'); + writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'required_mcp:\n - github-mcp'); + const { repo, updateJobCalls } = makeRepoMock(); + const worker = new Worker( + 'worker-1', + 'http://localhost:11434/v1', + 'test-model', + repo as never, + makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }), + ); + + // When: executeJob を最後まで走らせる + await (worker as unknown as { executeJob: (job: Job) => Promise }).executeJob( + makeLocalTaskJob({}), + ); + + // Then: wait_reason='mcp_auth_required' を含む park パッチが永続化される + //(resume_movement は piece の initial_movement) + const parkCall = updateJobCalls.find(([, patch]) => patch.status === 'waiting_human'); + expect(parkCall).toBeDefined(); + expect(parkCall![1]).toEqual({ + status: 'waiting_human', + waitReason: 'mcp_auth_required', + resumeMovement: 'respond', + }); + // Then: ユーザーに system イベントコメントで連携を促す + expect(repo.addLocalTaskComment).toHaveBeenCalledWith( + 77, + 'system', + expect.stringContaining('github-mcp'), + 'event', + ); + // Then: 失敗扱いにはならず、issue ロックは finally で必ず解放される + expect(updateJobCalls.some(([, patch]) => patch.status === 'failed')).toBe(false); + expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77); + }); + + it('requeues the job (status=queued, workerId=null) when the piece pins a model this worker cannot serve', async () => { + // Given: model を pin した custom piece と、そのモデルを持たない direct worker + const root = mkdtempSync(join(tmpdir(), 'worker-gate-model-')); + const userFolderRoot = join(root, 'users'); + writeCustomPiece(userFolderRoot, 'pin-gate-piece', 'model: exotic-model'); + const { repo, updateJobCalls } = makeRepoMock(); + const worker = new Worker( + 'worker-1', + 'http://localhost:11434/v1', + 'test-model', + repo as never, + makeConfig({ worktreeDir: join(root, 'worktree'), userFolderRoot }), + ); + // initialize() 相当: probed available models(exotic-model を含まない) + (worker as unknown as { availableModels: Set }).availableModels = new Set(['test-model']); + + // When + await (worker as unknown as { executeJob: (job: Job) => Promise }).executeJob( + makeLocalTaskJob({}), + ); + + // Then: queued に戻し worker を外す(他 worker が拾えるように) + const requeueCall = updateJobCalls.find(([, patch]) => patch.status === 'queued'); + expect(requeueCall).toBeDefined(); + expect(requeueCall![1]).toEqual({ + status: 'queued', + workerId: null, + errorSummary: 'Required model exotic-model is not available on worker-1', + }); + expect(repo.addAuditLog).toHaveBeenCalledWith('job-gate-1', 'job_requeued_model_mismatch', 'worker', { + workerId: 'worker-1', + requiredModel: 'exotic-model', + availableModels: ['test-model'], + }); + // Then: park はしない + expect(updateJobCalls.some(([, patch]) => patch.status === 'waiting_human')).toBe(false); + expect(repo.unlockIssue).toHaveBeenCalledWith('local/task-77', 77); + }); +}); diff --git a/src/worker.ts b/src/worker.ts index 5e713b8..e3300e0 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -21,6 +21,7 @@ import { ensureKeepaGraphs } from './engine/tools/amazon.js'; import type { McpTokenManager } from './mcp/token-manager.js'; import { mergeMcpConfig } from './mcp/config.js'; import { createStickyBackendResolver } from './worker/sticky-backend.js'; +import { mapPieceStatusToMetric, type JobMetricStatus } from './worker/piece-metric-status.js'; import { pickIdlerIndex } from './worker/idle-routing.js'; import { jobEventBus } from './bridge/job-events.js'; import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js'; @@ -1187,6 +1188,979 @@ export class Worker { return true; } + /** + * task_kind='reflection' の実行パス(executeJob から委譲)。workspace 準備と + * agent / LLM ループをバイパスして固定ハンドラ (handleReflectionJob) を走らせる。 + * finally の health 更新 + issue unlock は agent パスの finally と同じ後始末。 + */ + private async runReflectionJobPath(job: Job): Promise { + const { repo: repoName, issueNumber } = job; + try { + await this.handleReflectionJob(job); + } finally { + await this.repo.updateWorkerNodeHealth(this.workerId, { + healthy: this.healthy, + lastError: this.lastHealthError, + inflightJobs: this.inflight, + availableModels: [...this.availableModels], + }); + await this.repo.unlockIssue(repoName, issueNumber); + } + } + + /** + * 進捗レポーターの runtime_dir 解決(executeJob から切り出し)。 + * 計画5 (Opus P1): reporter は logPath を構築時に固定するため、runtime_dir を + * 構築前に解決しておく必要がある。persistent スペースタスクは local_task の + * runtime_dir(space/{id}/runs/{taskId})を使い、reader(file API の logs + * セクション = logRoot(task))と同じ dir を指すことで writer/reader の + * split-brain を防ぐ。localTaskId / runtime_dir が無い ephemeral / legacy / + * gitea-issue では undefined となり、reporter は workspacePath/logs に + * フォールバックして従来と byte 一致する。 + */ + private async resolveReporterRuntime( + job: Job, + localTaskId: number | null, + ): Promise<{ + localTaskForRuntime: { runtimeDir?: string | null; workspaceMode?: string | null; spaceId?: string | null } | undefined; + reporterRuntimeDir: ReturnType; + }> { + const jobId = job.id; + let localTaskForRuntime: { runtimeDir?: string | null; workspaceMode?: string | null; spaceId?: string | null } | undefined; + if (localTaskId != null) { + try { + const lt = await this.repo.getLocalTask(localTaskId); + localTaskForRuntime = lt ?? undefined; + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} getLocalTask for reporter runtime_dir failed, falling back to workspace/logs: ${err}`); + } + } + const reporterRuntimeDir = resolveReporterRuntimeDir({ + jobRuntimeDir: job.runtimeDir, + localTaskRuntimeDir: localTaskForRuntime?.runtimeDir, + }); + return { localTaskForRuntime, reporterRuntimeDir }; + } + + /** + * Agent 実行パスの space / folder / runtime コンテキスト解決(executeJob から + * 切り出し)。piece のロード元 custom ディレクトリ、conflict detection の要否、 + * 実行ログ root、per-space MCP/SSH の実効 spaceId を確定する。ロジック・ + * 条件・順序は移動のみで不変(元コメントも保持)。 + */ + private async resolveRunSpaceContext( + job: Job, + localTaskForRuntime: { runtimeDir?: string | null; workspaceMode?: string | null; spaceId?: string | null } | undefined, + ): Promise<{ + folderContext: { rootDir: string; leafId: string } | undefined; + conflictDetection: boolean | undefined; + runtimeDir: string | undefined; + conflictDir: string | undefined; + spaceId: string | undefined; + customPieceDirs: string[]; + }> { + const jobId = job.id; + // Piece 読み込み: per-user カスタムディレクトリ → global カスタムディレクトリ → builtin の順に探索 + // No-auth jobs (ownerId null) resolve pieces from data/users/local/pieces, matching + // where no-auth POST now writes (LOCAL_OWNER='local' in pieces-api.ts). + const userFolderRoot = this.config.userFolderRoot ?? './data/users'; + const ownerForPieces = job.ownerId ?? 'local'; + // Spaces foundation: for LOCAL tasks, resolve the effective space folder + // (data/users/{owner} for personal / null space_id, or data/spaces/{id} + // for case spaces). Gitea-issue jobs (no local task id) stay undefined => + // legacy personal-folder resolution (backward compatible). + let folderContext: { rootDir: string; leafId: string } | undefined; + const localTaskIdForSpace = getLocalTaskId(job.repo); + // Spaces foundation (plan 3): enable conflict detection only for persistent + // (shared) local-task workspaces. Ephemeral / gitea-issue jobs leave it OFF + // (undefined) → legacy byte-identical Read/Write/Edit behaviour. + let conflictDetection: boolean | undefined; + // 計画5: タスクの実行ログ root。persistent スペースタスクは local_task の + // runtime_dir(space/{id}/runs/{taskId})、subtask は親から継承した + // jobs.runtime_dir を使う。どちらも無ければ undefined= runPiece / reporter が + // workspacePath/logs にフォールバック(ephemeral / legacy)。 + let runtimeDir: string | undefined = job.runtimeDir ?? undefined; + // 計画5 (spec §10.3): 競合台帳 dir。スペースタスクは space/{id}/.conflict + // (files の外)、それ以外は ToolContext 側で workspacePath/.conflict に + // フォールバック(後方互換)。undefined のまま渡してフォールバックさせる。 + let conflictDir: string | undefined; + // Spaces foundation: the space this run belongs to. Threaded into + // ToolContext.spaceId and CONSUMED for per-space MCP/SSH resolution + // (Phases 2-3). Resolved from the local task's space_id (preferred), but + // INITIALIZED from the trusted jobs.space_id (set server-side at enqueue) + // so a getLocalTask exception below degrades to the job's own space — + // NEVER fails open to the global NULL-space MCP/SSH set (Opus security P2). + // Undefined only for gitea-issue jobs / genuinely space-less tasks. + let spaceId: string | undefined = job.spaceId ?? undefined; + if (localTaskIdForSpace != null) { + try { + folderContext = await this.repo.resolveTaskFolderContext(localTaskIdForSpace, userFolderRoot); + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} resolveTaskFolderContext failed, using legacy personal folder: ${err}`); + } + // 計画5 (Opus P1): reporter 構築時に取得済みの local_task 行を再利用する + // (localTaskIdForSpace === localTaskId で同一行)。フェッチ失敗で未取得の + // 場合のみ防御的に再取得する。 + try { + const localTaskForMode = localTaskForRuntime ?? await this.repo.getLocalTask(localTaskIdForSpace); + conflictDetection = localTaskForMode?.workspaceMode === 'persistent'; + runtimeDir = localTaskForMode?.runtimeDir ?? undefined; + // Prefer the local task's space; fall back to the job's space_id. + spaceId = localTaskForMode?.spaceId ?? job.spaceId ?? undefined; + if (conflictDetection && localTaskForMode?.spaceId) { + conflictDir = spaceConflictDir(this.config.worktreeDir, localTaskForMode.spaceId); + } + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} getLocalTask for workspaceMode failed, conflict detection OFF: ${err}`); + } + } + // Personal-workspace tasks carry space_id=NULL, but the user registered + // their MCP/SSH servers against their real personal-space id. Mirror what + // resolveTaskFolderContext does for folders: resolve NULL+owner to the + // owner's personal space so per-space MCP/SSH enumeration AND the + // execute-time guard (aggregator.executeTool) both see those servers. + // Applied to EVERY job (not just the local-task branch) so spawned + // subtasks — which inherit the parent's NULL space and don't re-enter the + // block above — also resolve to the owner's personal space. Case-space + // jobs keep their id; no-auth jobs (no owner) stay NULL-space (legacy); + // a resolver failure fails closed (sentinel), never the global NULL set. + // See docs/superpowers/specs/2026-06-19-personal-space-mcp-resolution-design.md + spaceId = await resolveToolSpaceId(spaceId, job.ownerId, (o) => + this.repo.ensurePersonalSpace(o).then((s) => s.id), + ); + // Custom-piece load dir: prefer the resolved space folder when present. + const pieceRootDir = folderContext?.rootDir ?? userFolderRoot; + const pieceLeafId = folderContext?.leafId ?? ownerForPieces; + const customPieceDirs = [ + userPiecesDir(pieceRootDir, pieceLeafId), + this.config.customPiecesDir, + ].filter((d): d is string => !!d); + return { folderContext, conflictDetection, runtimeDir, conflictDir, spaceId, customPieceDirs }; + } + + /** + * Model-mismatch requeue gate (direct mode only — gateway routes by + * role, see shouldRequeueForModelMismatch). executeJob から切り出し。 + * true を返したら呼び出し側は即 return(ジョブは queued に戻し済み)。 + */ + private async requeueOnModelMismatch(job: Job, piece: PieceDef): Promise { + const jobId = job.id; + if ( + shouldRequeueForModelMismatch({ + isGateway: this.getWorkerDef().proxy === true, + pieceModel: piece.model, + availableModels: this.availableModels, + workerModel: this.model, + }) + ) { + await this.repo.updateJob(jobId, { + status: 'queued', + workerId: null, + errorSummary: `Required model ${piece.model} is not available on ${this.workerId}`, + }); + await this.repo.addAuditLog(jobId, 'job_requeued_model_mismatch', 'worker', { + workerId: this.workerId, + requiredModel: piece.model, + availableModels: [...this.availableModels], + }); + logger.info(`[worker:${this.workerId}] requeued job ${jobId} due to model mismatch (${piece.model})`); + return true; + } + return false; + } + + /** + * MCP 認証ゲート: piece.required_mcp に記載されたサーバーのトークンがなければ park。 + * executeJob から切り出し。wait_reason='mcp_auth_required' の永続化は + * job-recovery(トークン取得時の自動再キュー)の前提条件なので変更禁止。 + * true を返したら呼び出し側は即 return(ジョブは waiting_human で停車済み)。 + */ + private async parkOnMissingMcp(job: Job, piece: PieceDef, localTaskId: number | null): Promise { + const jobId = job.id; + const missingMcp = (piece.required_mcp ?? []).filter( + (serverId) => !this.mcpTokenManager || !this.mcpTokenManager.hasToken(job.ownerId ?? '', serverId), + ); + if (missingMcp.length > 0) { + await this.repo.updateJob(jobId, { + status: 'waiting_human', + waitReason: 'mcp_auth_required', + resumeMovement: piece.initial_movement ?? null, + }); + if (localTaskId !== null) { + await this.repo.addLocalTaskComment( + localTaskId, + 'system', + `この piece は MCP サーバー「${missingMcp.join(', ')}」との連携が必要です。Settings → MCP 接続から連携してください。`, + 'event', + ); + } + logger.info(`[worker:${this.workerId}] mcp gate parked job=${jobId} missing=${missingMcp.join(',')}`); + return true; + } + return false; + } + + /** + * このジョブ用の LLM クライアント構築(executeJob から切り出し)。 + * Gateway routes by role; direct resolves the worker's model. The + * resolver thunk runs only in direct mode (no auto-select via gateway). + */ + private createJobLlmClient( + job: Job, + piece: PieceDef, + reporter: LocalProgressReporter, + ): { llmClient: OpenAICompatClient; isProxyWorker: boolean } { + const workerDefForLlm = this.getWorkerDef(); + const isProxyWorker = workerDefForLlm.proxy === true; + const resolvedModel = llmRoutingKey({ + isGateway: isProxyWorker, + role: job.requiredRole, + resolveDirectModel: () => this.resolveModel(piece), + }); + const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000; + const llmClient = new OpenAICompatClient( + this.endpoint, + resolvedModel, + workerDefForLlm.apiKey, + this.config.provider.retry, + timeoutMs, + this.contextLimitTokens, + this.config.safety?.promptGuardRatio, + (line) => reporter.reportPromptPreflight(line), + { proxy: isProxyWorker, maxStreamMs: this.resolveMaxStreamMs() }, + ); + return { llmClient, isProxyWorker }; + } + + /** + * runPiece に渡す piece 実行オプション(ASK 再開・割り込み注入・SpawnSubTask・ + * 未消化サブタスク検知)の構築。executeJob から切り出し(copy-and-delete)。 + * spawnSubTask 閉包の zombie guard / TOCTOU 再チェック / inheritJobContext に + * よる親→子継承(所有権・可視性・スペース・browser_session)は行単位で不変。 + */ + private buildPieceRunOptions(args: { + job: Job; + workspacePath: string; + isLocalTask: boolean; + localTaskId: number | null; + isSubTask: boolean; + reporter: LocalProgressReporter; + jobAbortController: AbortController; + cancelCheck: () => boolean; + }) { + const { job, workspacePath, isLocalTask, localTaskId, isSubTask, reporter, jobAbortController, cancelCheck } = args; + const jobId = job.id; + return { + resumeMovement: job.resumeMovement ?? undefined, + askCount: job.askCount, + maxAskPerJob: this.config.ask.maxPerJob, + checkInterjections: isLocalTask && localTaskId !== null && !isSubTask + ? async (movementName: string) => { + const comments = await this.repo.getUninjectedComments(localTaskId); + if (comments.length === 0) return []; + const injected = comments.map(c => ({ id: c.id, body: c.body })); + this.repo.markCommentsInjected(injected.map(c => c.id)); + reporter.reportInterjectionAck(injected, movementName); + return injected; + } + : undefined, + spawnSubTask: job.subtaskDepth < this.config.subtasks.maxDepth + ? async (params: { title: string; instruction: string; piece?: string }) => { + // Zombie guard: after a deadline/user abort — especially a ②B + // hard-kill that already ran cancelPendingChildren and marked the + // job terminal — a detached runPiece can still reach SpawnSubTask. + // Refuse to enqueue a NEW orphan child: nothing live will consume + // it, and it would burn a worker slot under an already-cancelled + // parent. The abort signal catches the in-grace window (terminal + // status not written yet); the status check catches post-hardkill. + if (!canSpawnSubtask(jobAbortController.signal.aborted, this.repo.getJobStatusSync(jobId) ?? '')) { + throw new Error('ジョブが終了またはキャンセルされたため、新しいサブタスクは作成できません。'); + } + const subJobs = await this.repo.getSubJobs(jobId); + const subtaskIndex = subJobs.length + 1; + if (subJobs.length >= this.config.subtasks.maxPerParent) { + throw new Error(`サブタスク上限 (${this.config.subtasks.maxPerParent}) に達しました。これ以上のサブタスクは作成できません。`); + } + const subtaskWorkspace = join(workspacePath, 'subtasks', String(subtaskIndex)); + mkdirSync(subtaskWorkspace, { recursive: true }); + mkdirSync(join(subtaskWorkspace, 'output'), { recursive: true }); + mkdirSync(join(subtaskWorkspace, 'logs'), { recursive: true }); + // 計画5 (Opus P1): サブタスクは親と分離した自前の isolated worktree + // (subtaskWorkspace)で実行されるため、その activity.log は共有されず + // resume poisoning の対象にならない。よって runtime_dir はネストせず + // 未設定のままにし、reporter を subtaskWorkspace/logs にフォールバック + // させる。これは subtask の reader(subtask-activity-api / + // subtask-files-api が worktreePath/logs を読む)と byte 一致し、 + // writer/reader の split-brain を防ぐ。 + + // 親ジョブの role を継承 + const subJobInstruction = [ + `ui_profile: ${job.requiredRole}`, + '', + `# ${params.title}`, + '', + params.instruction, + ].join('\n'); + + const subJob = await this.repo.createJob({ + repo: `subtask/${jobId}`, + issueNumber: subtaskIndex, + instruction: subJobInstruction, + pieceName: params.piece ?? 'general', + parentJobId: jobId, + subtaskDepth: job.subtaskDepth + 1, + maxAttempts: 2, + role: job.requiredRole, + // 親ジョブから所有権・可視性・スペース・ブラウザセッションのバインドを + // 継承する(spec §5.7 のスペース継承 + 保存セッションの引き継ぎ)。 + // browserSessionProfileId が無いとサブタスク内の BrowseWeb が + // 保存セッション (ログイン Cookie) を使えず未ログインでアクセスする。 + ...inheritJobContext(job), + // 計画5 (Opus P1): サブタスクは isolated worktree で実行され共有 + // されないため runtime_dir はネストしない(reporter は + // subtaskWorkspace/logs にフォールバックし reader と一致する)。 + runtimeDir: null, + }); + await this.repo.updateJob(subJob.id, { worktreePath: subtaskWorkspace }); + // TOCTOU close: the abort/hard-kill can land between the entry + // guard and this insert — finalizeHardkill's cancelPendingChildren + // reads getSubJobs *before* this child row exists and so misses it. + // Re-check now: if the parent is no longer live, cancel the child we + // just created (it would otherwise run as an orphan under a + // cancelled parent) and refuse the spawn. finalizeHardkill writes + // the terminal status before cancelPendingChildren, so a status read + // here reliably reflects an in-flight hard-kill. + if (!canSpawnSubtask(jobAbortController.signal.aborted, this.repo.getJobStatusSync(jobId) ?? '')) { + await this.repo.updateJob(subJob.id, { status: 'cancelled' }); + logger.info(`[worker:${this.workerId}] cancelled just-spawned sub-task #${subtaskIndex} job=${subJob.id} (parent no longer live)`); + throw new Error('ジョブが終了またはキャンセルされたため、新しいサブタスクは作成できません。'); + } + logger.info(`[worker:${this.workerId}] spawned sub-task #${subtaskIndex} depth=${job.subtaskDepth + 1} job=${subJob.id}`); + // ④ spawn stagger: brief delay so a burst of N SpawnSubTask enqueues + // doesn't all land in the same instant and jump GPU-slot priority over + // other queued work. Throttle only — NOT a completion-wait. Honors + // cancel so a cancelled parent doesn't sit sleeping. + if (this.config.subtasks.spawnStaggerMs > 0) { + await sleepWithCancel(this.config.subtasks.spawnStaggerMs, cancelCheck); + } + return { jobId: subJob.id, subtaskIndex, workspacePath: subtaskWorkspace }; + } + : undefined, + // WaitSubTask / auto-park support: report whether this job has unwaited + // pending children, and cancel them when the parent ends without consuming + // them (orphan prevention). Both use getSubJobs (latest revision per index). + hasPendingSubtasks: async (): Promise => { + const subs = await this.repo.getSubJobs(jobId); + return subs.some((s) => !SUBTASK_DB_TERMINAL.includes(s.status)); + }, + }; + } + + /** + * Browser session profile binding(executeJob から切り出し)。 + * If this job is bound to a browser_session_profile, decrypt the + * captured Playwright storageState and pass it into runPiece so + * BrowseWeb can inject it into BrowserContext. Owner-mismatch and + * expired-profile checks fail-fast before the agent loop starts + * (throw はそのまま executeJob の catch → scheduleRetryOrFail に伝播)。 + */ + private resolveBrowserSessionBinding( + job: Job, + localTaskId: number | null, + ): { + browserSessionState: object | undefined; + browserSessionProfileId: number | undefined; + browserSessionProfile: { loggedInSelector: string | null; loginUrlPatterns: string[] } | undefined; + onAuthExpired: ((profileId: number, reason: string) => void) | undefined; + } { + let browserSessionState: object | undefined; + let browserSessionProfileId: number | undefined; + let browserSessionProfile: + | { loggedInSelector: string | null; loginUrlPatterns: string[] } + | undefined; + let onAuthExpired: + | ((profileId: number, reason: string) => void) + | undefined; + + if (job.browserSessionProfileId) { + const sessRepo = new BrowserSessionRepo(this.repo.getDb()); + const profile = sessRepo.getProfileByIdUnsafe(job.browserSessionProfileId); + if (!profile) { + sessRepo.audit({ + actorUserId: job.ownerId ?? null, + ownerId: null, + profileId: job.browserSessionProfileId, + action: 'use', + result: 'error', + reason: 'profile not found', + jobId: job.id, + }); + throw new Error(`Browser session profile ${job.browserSessionProfileId} not found`); + } + // Fail-closed owner check: a job with null/missing ownerId must not + // be allowed to decrypt any profile, even if the profile id would + // otherwise resolve. Helper audits + throws on rejection. Extracted + // to src/engine/browser-session-auth.ts so the contract is unit + // tested in isolation from the Worker class. + assertProfileOwner(profile, job, sessRepo); + if (profile.status !== 'active' || !profile.encryptedStateBlob) { + sessRepo.audit({ + actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: profile.id, + action: 'use', result: 'error', reason: `status=${profile.status}`, jobId: job.id, + }); + throw new Error(`AUTH_SESSION_EXPIRED: profile ${profile.id} status=${profile.status}`); + } + const masterKeyPath = this.config.secrets?.masterKeyPath ?? './data/secrets/master.key'; + // Space-as-Principal P2: space-owned profiles decrypt under the space DEK + // (with owner-DEK fallback for pre-migration blobs); personal under owner DEK. + let stateJson: string; + try { + stateJson = decryptProfileState({ masterKeyPath, sessRepo }, { + ownerId: profile.ownerId, + spaceId: profile.spaceId, + encryptedStateBlob: profile.encryptedStateBlob, + }); + } catch (e) { + sessRepo.audit({ + actorUserId: job.ownerId, + ownerId: profile.ownerId, + profileId: profile.id, + action: 'decrypt', + result: 'error', + reason: `state decrypt failed: ${(e as Error).message}`, + jobId: job.id, + }); + throw e; + } + browserSessionState = JSON.parse(stateJson) as object; + browserSessionProfileId = profile.id; + browserSessionProfile = { + loggedInSelector: profile.loggedInSelector, + loginUrlPatterns: profile.loginUrlPatterns, + }; + onAuthExpired = (pid, reason) => { + sessRepo.markProfileStatus(pid, 'expired', reason); + sessRepo.audit({ + actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: pid, + action: 'expire', result: 'success', reason, jobId: job.id, + }); + // Best-effort task-level notification. Subtask jobs and + // gitea-issue jobs may not have a numeric local_task id. + if (localTaskId !== null) { + this.repo.addLocalTaskComment( + localTaskId, + 'agent', + `⚠️ Browser session "${profile.label}" expired: ${reason}. Re-login from Settings → Browser Sessions.`, + 'progress', + ).catch(() => { /* ignore — comment posting is best-effort */ }); + } + }; + sessRepo.audit({ + actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: profile.id, + action: 'use', result: 'success', jobId: job.id, + }); + sessRepo.touchUsed(profile.id); + } + return { browserSessionState, browserSessionProfileId, browserSessionProfile, onAuthExpired }; + } + + /** + * LLM に渡す指示コンテキストの組み立て(executeJob から切り出し)。 + * + * Piece handoff: when this job continues an earlier one in the same + * local_task, agent-loop injects a "this is a continuation of piece X" + * block into the system prompt. We resolve the prev piece name + the + * most recent agent result/ask comment as the LLM-visible carry-over. + * + * 会話コンテキスト P3: この継続ジョブが transcript を再生する場合 + * (handoffContext あり && 再生可能ターンあり = piece-runner の再生条件と完全一致)、 + * 再生した実ターンと重複するコメント要約ブロックを抑制する。transcriptPath は + * runPiece に渡す runtimeDir から算出するため piece-runner の logsRoot と一致し、 + * 抑制判定と再生判定の split-brain が構造的に起きない。 + */ + private async buildInstructionContext( + job: Job, + workspacePath: string, + localTaskId: number | null, + runtimeDir: string | undefined, + ): Promise<{ + handoffContext: import('./engine/agent-loop.js').HandoffContext | undefined; + transcriptPath: string; + enrichedInstruction: string; + }> { + const jobId = job.id; + const isLocalTask = localTaskId !== null; + let handoffContext: import('./engine/agent-loop.js').HandoffContext | undefined; + if (job.continuedFromJobId && isLocalTask && localTaskId !== null) { + const prevJob = await this.repo.getJob(job.continuedFromJobId); + if (prevJob) { + const prevResultComment = await this.repo.getLatestResultComment(localTaskId); + handoffContext = { + prevPiece: prevJob.pieceName, + prevResult: prevResultComment?.body ?? null, + }; + } else { + logger.warn(`[worker:${this.workerId}] continued_from_job_id=${job.continuedFromJobId} not found for job ${jobId}; skipping handoff context`); + } + } + + const transcriptPath = join(runtimeDir ?? join(workspacePath, 'logs'), 'transcript.jsonl'); + const willReplayTranscript = + handoffContext !== undefined && Conversation.hasReplayableTranscript(transcriptPath); + + let enrichedInstruction = `${buildTimeContextBlock()}${job.instruction}`; + if (isLocalTask) { + try { + const comments = await this.repo.listLocalTaskComments(localTaskId); + const outputFiles = this.listDir(join(workspacePath, 'output')); + const inputFiles = this.listDir(join(workspacePath, 'input')); + const contextBody = buildLocalConversationContext({ + comments, + jobInstruction: job.instruction, + inputFiles, + outputFiles, + omitRecentConversation: willReplayTranscript, + }); + enrichedInstruction = `${buildTimeContextBlock()}${contextBody}`; + } catch (err) { + logger.warn(`[worker:${this.workerId}] failed to build local context: ${err}`); + } + } + const retryHandoffContext = buildRetryHandoffContext(workspacePath, job); + if (retryHandoffContext) { + enrichedInstruction = `${enrichedInstruction}\n\n${retryHandoffContext}`; + } + return { handoffContext, transcriptPath, enrichedInstruction }; + } + + /** + * Parse per-task options from job payload (e.g. { options: { mcpDisabled, skillsDisabled } }). + * executeJob から切り出し。パース失敗は warn のみ(従来どおりジョブは続行)。 + */ + private resolveJobPayloadOptions(job: Job): { mcpDisabled: boolean; skillsDisabled: boolean } { + const jobId = job.id; + let jobPayloadOptions: Record = {}; + if (job.payload) { + try { + const parsed = JSON.parse(job.payload) as Record; + if (parsed?.options && typeof parsed.options === 'object' && !Array.isArray(parsed.options)) { + jobPayloadOptions = parsed.options as Record; + } + } catch { + logger.warn(`[worker:${this.workerId}] job ${jobId} failed to parse payload JSON`); + } + } + const mcpDisabled = jobPayloadOptions.mcpDisabled === true; + const skillsDisabled = jobPayloadOptions.skillsDisabled === true; + return { mcpDisabled, skillsDisabled }; + } + + /** + * Resolve the job owner's role to gate admin-only tool actions + * (e.g. system-scope InstallSkill). Defaults to 'user' when the owner + * is unknown (no-auth mode) or the lookup fails. executeJob から切り出し。 + */ + private resolveJobUserRole(job: Job): 'admin' | 'user' { + const jobId = job.id; + let userRole: 'admin' | 'user' = 'user'; + if (job.ownerId) { + try { + const ownerRow = this.repo.getUserById(job.ownerId); + if (ownerRow?.role === 'admin') userRole = 'admin'; + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} owner role lookup failed: ${(err as Error).message}`); + } + } + return userRole; + } + + /** + * Workspace tool policy + SSH 接続スコープの解決(executeJob から切り出し)。 + * + * Workspace tool policy: resolve once per job, applied to every movement. + * Uses the already-resolved spaceId (personal-space NULL→real-id handled + * by resolveToolSpaceId in resolveRunSpaceContext). Fail-closed: any error → + * safe defaults (sensitive tools off). Never throws — a policy error must + * not crash a job. + * + * SSH workspace scoping: when the workspace policy enables the 'ssh' category, + * resolve the connection IDs available to this job's space. This space-scoped + * list is the SOLE SSH connection gate (piece-level allowed_ssh_connections was + * removed in PR B), mirroring how MCP uses listEnabledForSpace. spaceId is + * already personal-space-resolved (NULL→real-id), so personal-WS jobs find + * their connections via listBySpace(personalSpaceId). NEVER '*' wildcard — + * always an explicit id list so cross-space isolation is guaranteed. + */ + private async resolveWorkspaceToolContext( + job: Job, + spaceId: string | undefined, + ): Promise<{ + workspaceTools: { allowedTools: string[]; editAllowed: boolean }; + workspaceSshConnections: string[] | undefined; + }> { + const jobId = job.id; + let workspaceTools: { allowedTools: string[]; editAllowed: boolean }; + let parsedPolicy: import('./engine/workspace-tool-policy.js').WorkspaceToolPolicy; + try { + const policyJson = spaceId != null ? this.repo.getSpaceToolPolicy(spaceId) : null; + parsedPolicy = parseToolPolicy(policyJson); + workspaceTools = await resolveWorkspaceTools(parsedPolicy); + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} workspace tool policy resolution failed, using safe defaults: ${(err as Error).message}`); + parsedPolicy = {}; + workspaceTools = await resolveWorkspaceTools({}); + } + + let workspaceSshConnections: string[] | undefined; + if (parsedPolicy.enabledSensitive?.includes('ssh') && spaceId != null) { + try { + const sshSub = getSshSubsystem(); + if (sshSub !== null) { + workspaceSshConnections = sshSub.connectionRepo.listBySpace(spaceId).map((c) => c.id); + logger.debug(`[worker:${this.workerId}] job ${jobId} ssh workspace connections resolved count=${workspaceSshConnections.length} spaceId=${spaceId}`); + } else { + // SSH subsystem not initialised (e.g. test environment without bridge setup). + // Leave undefined → piece-runner treats it as [] (no connections → SSH rejects). + logger.debug(`[worker:${this.workerId}] job ${jobId} ssh enabled in policy but subsystem not initialised — no connections available`); + } + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} ssh connection resolution failed: ${(err as Error).message}`); + // Leave undefined → piece-runner treats it as [] (fail-closed, no connections). + } + } + return { workspaceTools, workspaceSshConnections }; + } + + /** + * runPiece の起動(executeJob から切り出し)。executeJob 側で解決済みの + * 実行コンテキストを束ねて runPiece の options に配線する。await はせず + * Promise を返す — 呼び出し側が ②B hard-kill レース (raceWithGrace) に + * かけるため。options の各項目・ゲート条件・コメントは行単位で不変。 + */ + private startPieceRun(args: { + job: Job; + piece: PieceDef; + enrichedInstruction: string; + llmClient: OpenAICompatClient; + workspacePath: string; + callbacks: PieceRunCallbacks; + toolsConfig: AppConfig['tools']; + pieceOptions: ReturnType; + cancelCheck: () => boolean; + jobAbortController: AbortController; + customPieceDirs: string[]; + folderContext: { rootDir: string; leafId: string } | undefined; + spaceId: string | undefined; + sessionIdentity: Awaited>; + conflictDetection: boolean | undefined; + runtimeDir: string | undefined; + conflictDir: string | undefined; + contextManager: ContextManager; + workerDef: WorkerDef; + handoffContext: import('./engine/agent-loop.js').HandoffContext | undefined; + transcriptPath: string; + isLocalTask: boolean; + localTaskId: number | null; + isSubTask: boolean; + parentJobId: string | null; + browserSessionState: object | undefined; + browserSessionProfileId: number | undefined; + browserSessionProfile: { loggedInSelector: string | null; loginUrlPatterns: string[] } | undefined; + onAuthExpired: ((profileId: number, reason: string) => void) | undefined; + mcpDisabled: boolean; + skillsDisabled: boolean; + userRole: 'admin' | 'user'; + workspaceTools: { allowedTools: string[]; editAllowed: boolean }; + workspaceSshConnections: string[] | undefined; + }): Promise { + const { + job, + piece, + enrichedInstruction, + llmClient, + workspacePath, + callbacks, + toolsConfig, + pieceOptions, + cancelCheck, + jobAbortController, + customPieceDirs, + folderContext, + spaceId, + sessionIdentity, + conflictDetection, + runtimeDir, + conflictDir, + contextManager, + workerDef, + handoffContext, + transcriptPath, + isLocalTask, + localTaskId, + isSubTask, + parentJobId, + browserSessionState, + browserSessionProfileId, + browserSessionProfile, + onAuthExpired, + mcpDisabled, + skillsDisabled, + userRole, + workspaceTools, + workspaceSshConnections, + } = args; + const { id: jobId, issueNumber } = job; + return runPiece(piece, enrichedInstruction, llmClient, workspacePath, callbacks, toolsConfig, { + ...pieceOptions, + cancelCheck, + abortController: jobAbortController, + safetyConfig: this.config.safety, + searchFilter: this.config.searchFilter, + customPiecesDir: customPieceDirs, + // Spaces foundation: resolved space folder for AGENTS.md / memory / + // custom-piece resolution. Undefined for gitea-issue jobs => legacy. + folderContext, + // Spaces foundation: space id for future per-space MCP/SSH resolution. + // Phase 1 no-op (no consumer reads ctx.spaceId yet). + spaceId, + // Per-space Python package overlay (admin-installed wheels). Resolve the + // stable `current` symlink path so Bash can bind it read-only + expose it + // on PYTHONPATH. undefined when the feature is disabled; the Bash tool + // also no-ops if the path does not exist yet (nothing installed). + pythonPackagesDir: (() => { + const pk = resolvePythonPackagesConfig(this.config.pythonPackages); + if (!pk.enabled) return undefined; + return spaceCurrentDir(pk.dir, spaceKeyFor(spaceId, sessionIdentity.userId ?? job.ownerId ?? 'local')); + })(), + // Spaces foundation (plan 3): conflict detection for persistent local-task + // workspaces. Undefined (OFF) for ephemeral / gitea-issue jobs => legacy. + conflictDetection, + // 計画5: 実行ログ root + 競合台帳 dir。スペースタスクのみ非 undefined。 + // ephemeral / legacy は undefined = workspacePath/logs・workspacePath/.conflict(後方互換)。 + runtimeDir, + conflictDir, + contextManager, + vlmEnabled: workerDef.vlm === true, + jobId, + handoffContext, + // When this run IS a subtask, pass parent identity + child workspace + // path so the runner records the lineage in its run-start event. + // Subtask workspaces follow `/subtasks/` where N is the + // subtask job's issueNumber. + parentJobId: isSubTask && parentJobId ? parentJobId : undefined, + childWorkspaceRelative: isSubTask ? `subtasks/${issueNumber}` : undefined, + // Mission Brief: only wire IO when this run is bound to a local + // task (the brief is per-LocalTask, not per-job). Subtask runs + // and gitea-issue runs leave it unset → MissionUpdate degrades + // to a no-op and the system prompt MISSION block is skipped. + missionBrief: isLocalTask && localTaskId !== null && !isSubTask + ? this.repo.makeMissionBriefIO(localTaskId) + : undefined, + // Conversation recall: bind SearchTaskConversation / ReadTaskConversation + // to this local task's comments + this run's transcript for top-level + // local tasks; sub-tasks get a transcript-only variant (see the isSubTask + // branch below). gitea-issue runs leave it unset so the tools degrade + // gracefully. + // + // SECURITY (adversarial review): bind the transcript ONLY to a per-task + // `runtimeDir` (space/{spaceId}/runs/{taskId}). The old + // `runtimeDir ?? workspacePath/logs` fallback is UNSAFE for persistent + // space tasks — workspacePath is the space-SHARED files tree, so a + // NULL-runtime_dir task would expose another (possibly different-user) + // task's transcript in the same space. When runtimeDir is unset we pass + // no transcript path: comment search (always task_id-scoped) still works, + // transcript search simply returns nothing rather than risk a leak. + taskConversation: isLocalTask && localTaskId !== null && !isSubTask + ? this.repo.makeTaskConversationIO( + localTaskId, + runtimeDir ? join(runtimeDir, 'transcript.jsonl') : undefined, + ) + // サブタスクは backing local_task を持たない(comments 無し)が、自分の + // transcript は subtaskWorkspace/logs に書かれる。上で算出済みの + // transcriptPath(piece-runner の logsRoot と同一式)をそのまま再利用し、 + // writer/reader の drift を防ぐ。上の SECURITY 注記が禁じる workspacePath/logs + // フォールバックは「永続スペースの共有 workspacePath」が対象で、サブタスクの + // workspacePath は subtasks/ の隔離ディレクトリなので安全(他タスクの + // transcript には届かない)。workspaceTaskSearch は配線しない(他タスク横断は + // サブタスクに開かない — 設計 §2 非ゴール)。 + : isSubTask + ? this.repo.makeTranscriptOnlyConversationIO(transcriptPath) + : undefined, + // Workspace-wide task search: same gate as taskConversation, plus an + // owner id (needed to scope the search to the owner's space). In + // no-auth mode job.ownerId is null, so this is undefined and the tool + // degrades to a no-context message rather than searching unscoped. + workspaceTaskSearch: isLocalTask && localTaskId !== null && !isSubTask && job.ownerId + ? this.repo.makeWorkspaceTaskSearchIO(localTaskId, job.ownerId) + : undefined, + // File provenance: bind reads to THIS run's workspace_path (the scoping + // boundary — a shared space workspace is queried only for its own rows, + // never another workspace's / user's lineage) and a recorder bound to + // this task/job/space. Gated on a resolvable local task id (need a + // numeric created_by_task_id). Subtask / gitea-issue runs leave both + // unset → the agent tools degrade to a no-op and Write/Edit/Bash skip + // recording. + fileProvenance: + isLocalTask && localTaskId !== null + ? this.repo.makeFileProvenanceIO(workspacePath) + : undefined, + recordFileProvenance: + isLocalTask && localTaskId !== null + ? (ev) => + this.repo.recordProvenance({ + workspacePath, + relPath: ev.relPath, + event: ev.event, + sourceKind: ev.sourceKind, + checksum: ev.checksum ?? null, + taskId: localTaskId, + jobId, + spaceId: spaceId ?? null, + piece: ev.pieceName, + movement: ev.movementName, + }) + : undefined, + taskId: sessionIdentity.taskId, + userId: sessionIdentity.userId, + // Tool-request mechanism: per-task grant overlay, read fresh each run so + // a resume after inline approval picks up the newly-granted tool. + grantedTools: + isLocalTask && localTaskId !== null ? this.repo.getGrantedTools(String(localTaskId)) : undefined, + // Workspace tool policy: pre-resolved once per job above (fail-closed). + // Drives every movement's effective allowed tools and edit flag. + workspaceTools, + // SSH workspace scoping: connection IDs available to this job's space. + // Undefined when ssh not enabled in policy (movement declaration is fallback). + workspaceSshConnections, + // Pause for inline approval only when a user is reachable: a local task + // that is not itself a subtask (subtasks/headless runs auto-proceed). + toolApprovalInteractive: isLocalTask && !isSubTask, + // Tool-request mechanism: bind the recorder to this run's task/job/space. + // piece + movement are injected upstream by piece-runner. + recordToolRequest: (req) => + this.repo.recordToolRequest({ + ...req, + // Use the local task id (same key as granted_tools + the decide API) + // so the task-detail query and grant overlay line up. + taskId: localTaskId !== null ? String(localTaskId) : null, + jobId, + spaceId: spaceId ?? null, + }), + // Package-request mechanism (RequestPackage): bind the recorder + + // installed/declined state ONLY when the feature is enabled AND this run + // has a resolvable space. Approval installs into spaces.python_packages + // (keyed by space id) and the resumed job's overlay key is + // spaceKeyFor(spaceId) = spaceId, so the two must agree — hence the hard + // space requirement. No space => the tool reports "unavailable". + ...(() => { + const pk = resolvePythonPackagesConfig(this.config.pythonPackages); + if (!pk.enabled || spaceId == null) return {}; + // Installed set is name→spec (version-aware): a pinned request is only + // "already installed" on an exact spec match; a bare-name request + // matches any installed version. + let installedPackages: Array<{ name: string; spec: string }> = []; + try { + const raw = this.repo.getSpacePythonPackages(spaceId); + if (raw) { + const parsed = JSON.parse(raw) as { packages?: Array<{ name?: unknown; spec?: unknown }> }; + if (Array.isArray(parsed.packages)) { + installedPackages = parsed.packages + .map((p) => (typeof p?.name === 'string' && typeof p?.spec === 'string' ? { name: p.name, spec: p.spec } : null)) + .filter((p): p is { name: string; spec: string } => p !== null); + } + } + } catch { + // best-effort: a malformed desired-state must not fail the job. + } + // Names already declined for THIS job (deny/auto_deny) → RequestPackage + // short-circuits a re-request instead of re-parking (deny loop guard). + const declinedPackages = this.repo.listDeclinedPackageNamesByJob(jobId); + return { + installedPackages, + declinedPackages, + recordPackageRequest: (req: { + spec: string; + normalizedName: string; + reason?: string | null; + status?: 'pending' | 'approved' | 'denied' | 'auto_denied'; + pieceName?: string | null; + movementName?: string | null; + }) => + this.repo.recordPackageRequest({ + ...req, + taskId: localTaskId !== null ? String(localTaskId) : null, + jobId, + spaceId, + }), + }; + })(), + browserSessionState, + browserSessionProfileId, + browserSessionProfile, + onAuthExpired, + ownerId: job.ownerId, + mcpConfig: mergeMcpConfig(this.config.mcp), + userRole, + skillCatalog: this.skillCatalog ?? undefined, + mcpDisabled, + skillsDisabled, + }); + } + + /** + * piece 実行の完了処理(executeJob から切り出し)。②B hard-kill レース → + * 実行診断の書き出し → handlePieceResult(ジョブライフサイクル遷移・park + * 永続化・reflection 起票を含む)→ メトリクス status 確定、の順序は不変。 + * + * metricFinalStatus は setter 経由で書き込む: hard-kill 時は finalizeHardkill + * の【前】に 'cancelled' をセット(finalizeHardkill が throw しても finally の + * メトリクスが 'cancelled' で記録される従来挙動を保持)、通常時は + * handlePieceResult の【後】にセット(handlePieceResult が throw したら + * 'error' のまま catch に渡る従来挙動を保持)。hard-kill 時はここで処理を + * 打ち切って戻る(呼び出し側 try ブロックの末尾なので従来の early return と + * 同一の後続フロー = finally のみ実行)。 + */ + private async settlePieceRun(args: { + piecePromise: Promise; + jobAbortController: AbortController; + job: Job; + reporter: LocalProgressReporter; + workspacePath: string; + isLocalTask: boolean; + isSubTask: boolean; + parentJobId: string | null; + setMetricFinalStatus: (s: JobMetricStatus) => void; + }): Promise { + const { piecePromise, jobAbortController, job, reporter, workspacePath, isLocalTask, isSubTask, parentJobId, setMetricFinalStatus } = args; + const jobId = job.id; + // ②B deadline hard-kill safety net. Race the piece run against a grace + // timer that arms when the job's abort signal fires (deadline OR cancel). + // ②A wires the abort into the realistic blocking tools so a run normally + // unwinds cooperatively; if it does not within the grace window, hard-kill + // so the inflight slot is freed and the job reaches a terminal state. The + // detached run keeps executing in-process (accepted zombie residual). + // deadlineGraceSeconds <= 0 disables the ②B hard-kill fallback: await the + // run directly and rely on ②A's cooperative abort (matches the documented + // "0 disables" semantics, same convention as maxJobMinutes=0). + const graceSec = this.config.safety?.deadlineGraceSeconds ?? DEFAULT_DEADLINE_GRACE_SECONDS; + const raced = graceSec > 0 + ? await raceWithGrace(piecePromise, jobAbortController.signal, graceSec * 1000) + : { ok: true as const, value: await piecePromise }; + if (!raced.ok) { + logger.warn(`[worker:${this.workerId}] job ${jobId} did not unwind within grace=${graceSec}s after abort; hard-killing to release slot (deadline=${raced.deadline})`); + setMetricFinalStatus('cancelled'); + await this.finalizeHardkill(job, reporter, isSubTask, parentJobId, raced.deadline); + return; + } + const result = raced.value; + logger.info(`[worker:${this.workerId}] job ${jobId} runPiece done: status=${result.status}`); + this.writeRunDiagnostics(workspacePath, result); + + await this.handlePieceResult(result, job, reporter, workspacePath, isLocalTask, isSubTask, parentJobId); + + setMetricFinalStatus(mapPieceStatusToMetric(result.status)); + } + private async executeJob(job: Job): Promise { const { repo: repoName, issueNumber, id: jobId } = job; const localTaskId = getLocalTaskId(repoName); @@ -1231,17 +2205,7 @@ export class Worker { // Reflection jobs bypass workspace preparation and the agent / LLM loop. // task_kind='agent' (default) keeps the pre-existing piece-runner path. if (job.taskKind === 'reflection') { - try { - await this.handleReflectionJob(job); - } finally { - await this.repo.updateWorkerNodeHealth(this.workerId, { - healthy: this.healthy, - lastError: this.lastHealthError, - inflightJobs: this.inflight, - availableModels: [...this.availableModels], - }); - await this.repo.unlockIssue(repoName, issueNumber); - } + await this.runReflectionJobPath(job); return; } @@ -1250,26 +2214,7 @@ export class Worker { // 進捗レポーター // ローカルタスク・サブタスクともに activity.log を書き出す // サブタスクでは isSubTask=true を渡し、DB コメント書き込みをスキップする - // 計画5 (Opus P1): reporter は logPath を構築時に固定するため、runtime_dir を - // 構築前に解決しておく必要がある。persistent スペースタスクは local_task の - // runtime_dir(space/{id}/runs/{taskId})を使い、reader(file API の logs - // セクション = logRoot(task))と同じ dir を指すことで writer/reader の - // split-brain を防ぐ。localTaskId / runtime_dir が無い ephemeral / legacy / - // gitea-issue では undefined となり、reporter は workspacePath/logs に - // フォールバックして従来と byte 一致する。 - let localTaskForRuntime: { runtimeDir?: string | null; workspaceMode?: string | null; spaceId?: string | null } | undefined; - if (localTaskId != null) { - try { - const lt = await this.repo.getLocalTask(localTaskId); - localTaskForRuntime = lt ?? undefined; - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} getLocalTask for reporter runtime_dir failed, falling back to workspace/logs: ${err}`); - } - } - const reporterRuntimeDir = resolveReporterRuntimeDir({ - jobRuntimeDir: job.runtimeDir, - localTaskRuntimeDir: localTaskForRuntime?.runtimeDir, - }); + const { localTaskForRuntime, reporterRuntimeDir } = await this.resolveReporterRuntime(job, localTaskId); const reporter = new LocalProgressReporter(this.repo, localTaskId ?? issueNumber, workspacePath, logMetadata, isSubTask, reporterRuntimeDir); // 会話コンテキスト (enrichedInstruction) は handoffContext / runtimeDir 解決後に @@ -1281,256 +2226,38 @@ export class Worker { // 中断の即応 + ジョブ全体のハードデッドライン(fix: runaway stream cancel/reaper) let jobGuardTimer: ReturnType | undefined; try { - // Piece 読み込み: per-user カスタムディレクトリ → global カスタムディレクトリ → builtin の順に探索 - // No-auth jobs (ownerId null) resolve pieces from data/users/local/pieces, matching - // where no-auth POST now writes (LOCAL_OWNER='local' in pieces-api.ts). - const userFolderRoot = this.config.userFolderRoot ?? './data/users'; - const ownerForPieces = job.ownerId ?? 'local'; - // Spaces foundation: for LOCAL tasks, resolve the effective space folder - // (data/users/{owner} for personal / null space_id, or data/spaces/{id} - // for case spaces). Gitea-issue jobs (no local task id) stay undefined => - // legacy personal-folder resolution (backward compatible). - let folderContext: { rootDir: string; leafId: string } | undefined; - const localTaskIdForSpace = getLocalTaskId(job.repo); - // Spaces foundation (plan 3): enable conflict detection only for persistent - // (shared) local-task workspaces. Ephemeral / gitea-issue jobs leave it OFF - // (undefined) → legacy byte-identical Read/Write/Edit behaviour. - let conflictDetection: boolean | undefined; - // 計画5: タスクの実行ログ root。persistent スペースタスクは local_task の - // runtime_dir(space/{id}/runs/{taskId})、subtask は親から継承した - // jobs.runtime_dir を使う。どちらも無ければ undefined= runPiece / reporter が - // workspacePath/logs にフォールバック(ephemeral / legacy)。 - let runtimeDir: string | undefined = job.runtimeDir ?? undefined; - // 計画5 (spec §10.3): 競合台帳 dir。スペースタスクは space/{id}/.conflict - // (files の外)、それ以外は ToolContext 側で workspacePath/.conflict に - // フォールバック(後方互換)。undefined のまま渡してフォールバックさせる。 - let conflictDir: string | undefined; - // Spaces foundation: the space this run belongs to. Threaded into - // ToolContext.spaceId and CONSUMED for per-space MCP/SSH resolution - // (Phases 2-3). Resolved from the local task's space_id (preferred), but - // INITIALIZED from the trusted jobs.space_id (set server-side at enqueue) - // so a getLocalTask exception below degrades to the job's own space — - // NEVER fails open to the global NULL-space MCP/SSH set (Opus security P2). - // Undefined only for gitea-issue jobs / genuinely space-less tasks. - let spaceId: string | undefined = job.spaceId ?? undefined; - if (localTaskIdForSpace != null) { - try { - folderContext = await this.repo.resolveTaskFolderContext(localTaskIdForSpace, userFolderRoot); - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} resolveTaskFolderContext failed, using legacy personal folder: ${err}`); - } - // 計画5 (Opus P1): reporter 構築時に取得済みの local_task 行を再利用する - // (localTaskIdForSpace === localTaskId で同一行)。フェッチ失敗で未取得の - // 場合のみ防御的に再取得する。 - try { - const localTaskForMode = localTaskForRuntime ?? await this.repo.getLocalTask(localTaskIdForSpace); - conflictDetection = localTaskForMode?.workspaceMode === 'persistent'; - runtimeDir = localTaskForMode?.runtimeDir ?? undefined; - // Prefer the local task's space; fall back to the job's space_id. - spaceId = localTaskForMode?.spaceId ?? job.spaceId ?? undefined; - if (conflictDetection && localTaskForMode?.spaceId) { - conflictDir = spaceConflictDir(this.config.worktreeDir, localTaskForMode.spaceId); - } - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} getLocalTask for workspaceMode failed, conflict detection OFF: ${err}`); - } - } - // Personal-workspace tasks carry space_id=NULL, but the user registered - // their MCP/SSH servers against their real personal-space id. Mirror what - // resolveTaskFolderContext does for folders: resolve NULL+owner to the - // owner's personal space so per-space MCP/SSH enumeration AND the - // execute-time guard (aggregator.executeTool) both see those servers. - // Applied to EVERY job (not just the local-task branch) so spawned - // subtasks — which inherit the parent's NULL space and don't re-enter the - // block above — also resolve to the owner's personal space. Case-space - // jobs keep their id; no-auth jobs (no owner) stay NULL-space (legacy); - // a resolver failure fails closed (sentinel), never the global NULL set. - // See docs/superpowers/specs/2026-06-19-personal-space-mcp-resolution-design.md - spaceId = await resolveToolSpaceId(spaceId, job.ownerId, (o) => - this.repo.ensurePersonalSpace(o).then((s) => s.id), - ); - // Custom-piece load dir: prefer the resolved space folder when present. - const pieceRootDir = folderContext?.rootDir ?? userFolderRoot; - const pieceLeafId = folderContext?.leafId ?? ownerForPieces; - const customPieceDirs = [ - userPiecesDir(pieceRootDir, pieceLeafId), - this.config.customPiecesDir, - ].filter((d): d is string => !!d); + const { folderContext, conflictDetection, runtimeDir, conflictDir, spaceId, customPieceDirs } = + await this.resolveRunSpaceContext(job, localTaskForRuntime); logger.info(`[worker:${this.workerId}] job ${jobId} loadPiece piece=${job.pieceName} customDirs=[${customPieceDirs.join(', ') || 'none'}] piecesDir=pieces`); const piece = loadPiece(job.pieceName, 'pieces', customPieceDirs); - // Model-mismatch requeue gate (direct mode only — gateway routes by - // role, see shouldRequeueForModelMismatch). - if ( - shouldRequeueForModelMismatch({ - isGateway: this.getWorkerDef().proxy === true, - pieceModel: piece.model, - availableModels: this.availableModels, - workerModel: this.model, - }) - ) { - await this.repo.updateJob(jobId, { - status: 'queued', - workerId: null, - errorSummary: `Required model ${piece.model} is not available on ${this.workerId}`, - }); - await this.repo.addAuditLog(jobId, 'job_requeued_model_mismatch', 'worker', { - workerId: this.workerId, - requiredModel: piece.model, - availableModels: [...this.availableModels], - }); - logger.info(`[worker:${this.workerId}] requeued job ${jobId} due to model mismatch (${piece.model})`); - return; - } + if (await this.requeueOnModelMismatch(job, piece)) return; + if (await this.parkOnMissingMcp(job, piece, localTaskId)) return; - // MCP 認証ゲート: piece.required_mcp に記載されたサーバーのトークンがなければ park - const missingMcp = (piece.required_mcp ?? []).filter( - (serverId) => !this.mcpTokenManager || !this.mcpTokenManager.hasToken(job.ownerId ?? '', serverId), - ); - if (missingMcp.length > 0) { - await this.repo.updateJob(jobId, { - status: 'waiting_human', - waitReason: 'mcp_auth_required', - resumeMovement: piece.initial_movement ?? null, - }); - if (localTaskId !== null) { - await this.repo.addLocalTaskComment( - localTaskId, - 'system', - `この piece は MCP サーバー「${missingMcp.join(', ')}」との連携が必要です。Settings → MCP 接続から連携してください。`, - 'event', - ); + const { llmClient, isProxyWorker } = this.createJobLlmClient(job, piece, reporter); + + // キャンセル用 AbortController + const jobAbortController = new AbortController(); + + // キャンセルチェック: DB のジョブ状態が 'cancelled' になっていたら中断する + const cancelCheck = (): boolean => { + const isCancelled = this.repo.getJobStatusSync(jobId) === 'cancelled'; + if (isCancelled) { + jobAbortController.abort(); } - logger.info(`[worker:${this.workerId}] mcp gate parked job=${jobId} missing=${missingMcp.join(',')}`); - return; - } - - const workerDefForLlm = this.getWorkerDef(); - const isProxyWorker = workerDefForLlm.proxy === true; - // Gateway routes by role; direct resolves the worker's model. The - // resolver thunk runs only in direct mode (no auto-select via gateway). - const resolvedModel = llmRoutingKey({ - isGateway: isProxyWorker, - role: job.requiredRole, - resolveDirectModel: () => this.resolveModel(piece), - }); - const timeoutMs = (this.config.provider.timeoutMinutes ?? 10) * 60 * 1000; - const llmClient = new OpenAICompatClient( - this.endpoint, - resolvedModel, - workerDefForLlm.apiKey, - this.config.provider.retry, - timeoutMs, - this.contextLimitTokens, - this.config.safety?.promptGuardRatio, - (line) => reporter.reportPromptPreflight(line), - { proxy: isProxyWorker, maxStreamMs: this.resolveMaxStreamMs() }, - ); + return isCancelled; + }; // ASK 再開の場合、resume_movement を使用 - const pieceOptions = { - resumeMovement: job.resumeMovement ?? undefined, - askCount: job.askCount, - maxAskPerJob: this.config.ask.maxPerJob, - checkInterjections: isLocalTask && localTaskId !== null && !isSubTask - ? async (movementName: string) => { - const comments = await this.repo.getUninjectedComments(localTaskId); - if (comments.length === 0) return []; - const injected = comments.map(c => ({ id: c.id, body: c.body })); - this.repo.markCommentsInjected(injected.map(c => c.id)); - reporter.reportInterjectionAck(injected, movementName); - return injected; - } - : undefined, - spawnSubTask: job.subtaskDepth < this.config.subtasks.maxDepth - ? async (params: { title: string; instruction: string; piece?: string }) => { - // Zombie guard: after a deadline/user abort — especially a ②B - // hard-kill that already ran cancelPendingChildren and marked the - // job terminal — a detached runPiece can still reach SpawnSubTask. - // Refuse to enqueue a NEW orphan child: nothing live will consume - // it, and it would burn a worker slot under an already-cancelled - // parent. The abort signal catches the in-grace window (terminal - // status not written yet); the status check catches post-hardkill. - if (!canSpawnSubtask(jobAbortController.signal.aborted, this.repo.getJobStatusSync(jobId) ?? '')) { - throw new Error('ジョブが終了またはキャンセルされたため、新しいサブタスクは作成できません。'); - } - const subJobs = await this.repo.getSubJobs(jobId); - const subtaskIndex = subJobs.length + 1; - if (subJobs.length >= this.config.subtasks.maxPerParent) { - throw new Error(`サブタスク上限 (${this.config.subtasks.maxPerParent}) に達しました。これ以上のサブタスクは作成できません。`); - } - const subtaskWorkspace = join(workspacePath, 'subtasks', String(subtaskIndex)); - mkdirSync(subtaskWorkspace, { recursive: true }); - mkdirSync(join(subtaskWorkspace, 'output'), { recursive: true }); - mkdirSync(join(subtaskWorkspace, 'logs'), { recursive: true }); - // 計画5 (Opus P1): サブタスクは親と分離した自前の isolated worktree - // (subtaskWorkspace)で実行されるため、その activity.log は共有されず - // resume poisoning の対象にならない。よって runtime_dir はネストせず - // 未設定のままにし、reporter を subtaskWorkspace/logs にフォールバック - // させる。これは subtask の reader(subtask-activity-api / - // subtask-files-api が worktreePath/logs を読む)と byte 一致し、 - // writer/reader の split-brain を防ぐ。 - - // 親ジョブの role を継承 - const subJobInstruction = [ - `ui_profile: ${job.requiredRole}`, - '', - `# ${params.title}`, - '', - params.instruction, - ].join('\n'); - - const subJob = await this.repo.createJob({ - repo: `subtask/${jobId}`, - issueNumber: subtaskIndex, - instruction: subJobInstruction, - pieceName: params.piece ?? 'general', - parentJobId: jobId, - subtaskDepth: job.subtaskDepth + 1, - maxAttempts: 2, - role: job.requiredRole, - // 親ジョブから所有権・可視性・スペース・ブラウザセッションのバインドを - // 継承する(spec §5.7 のスペース継承 + 保存セッションの引き継ぎ)。 - // browserSessionProfileId が無いとサブタスク内の BrowseWeb が - // 保存セッション (ログイン Cookie) を使えず未ログインでアクセスする。 - ...inheritJobContext(job), - // 計画5 (Opus P1): サブタスクは isolated worktree で実行され共有 - // されないため runtime_dir はネストしない(reporter は - // subtaskWorkspace/logs にフォールバックし reader と一致する)。 - runtimeDir: null, - }); - await this.repo.updateJob(subJob.id, { worktreePath: subtaskWorkspace }); - // TOCTOU close: the abort/hard-kill can land between the entry - // guard and this insert — finalizeHardkill's cancelPendingChildren - // reads getSubJobs *before* this child row exists and so misses it. - // Re-check now: if the parent is no longer live, cancel the child we - // just created (it would otherwise run as an orphan under a - // cancelled parent) and refuse the spawn. finalizeHardkill writes - // the terminal status before cancelPendingChildren, so a status read - // here reliably reflects an in-flight hard-kill. - if (!canSpawnSubtask(jobAbortController.signal.aborted, this.repo.getJobStatusSync(jobId) ?? '')) { - await this.repo.updateJob(subJob.id, { status: 'cancelled' }); - logger.info(`[worker:${this.workerId}] cancelled just-spawned sub-task #${subtaskIndex} job=${subJob.id} (parent no longer live)`); - throw new Error('ジョブが終了またはキャンセルされたため、新しいサブタスクは作成できません。'); - } - logger.info(`[worker:${this.workerId}] spawned sub-task #${subtaskIndex} depth=${job.subtaskDepth + 1} job=${subJob.id}`); - // ④ spawn stagger: brief delay so a burst of N SpawnSubTask enqueues - // doesn't all land in the same instant and jump GPU-slot priority over - // other queued work. Throttle only — NOT a completion-wait. Honors - // cancel so a cancelled parent doesn't sit sleeping. - if (this.config.subtasks.spawnStaggerMs > 0) { - await sleepWithCancel(this.config.subtasks.spawnStaggerMs, cancelCheck); - } - return { jobId: subJob.id, subtaskIndex, workspacePath: subtaskWorkspace }; - } - : undefined, - // WaitSubTask / auto-park support: report whether this job has unwaited - // pending children, and cancel them when the parent ends without consuming - // them (orphan prevention). Both use getSubJobs (latest revision per index). - hasPendingSubtasks: async (): Promise => { - const subs = await this.repo.getSubJobs(jobId); - return subs.some((s) => !SUBTASK_DB_TERMINAL.includes(s.status)); - }, - }; + const pieceOptions = this.buildPieceRunOptions({ + job, + workspacePath, + isLocalTask, + localTaskId, + isSubTask, + reporter, + jobAbortController, + cancelCheck, + }); // サブジョブの delegate イベントを root 親ジョブへ中継するための job id を解決する。 // サブタスクでない通常ジョブは undefined(中継なし)。 @@ -1555,18 +2282,6 @@ export class Worker { // 開始コメント await reporter.reportMovementStart(`${piece.name} タスク開始`); - // キャンセル用 AbortController - const jobAbortController = new AbortController(); - - // キャンセルチェック: DB のジョブ状態が 'cancelled' になっていたら中断する - const cancelCheck = (): boolean => { - const isCancelled = this.repo.getJobStatusSync(jobId) === 'cancelled'; - if (isCancelled) { - jobAbortController.abort(); - } - return isCancelled; - }; - // ── ジョブガード ─────────────────────────────────────────────── // cancelCheck はエージェントループのイテレーション先頭でしか呼ばれない。 // LLM が停止トークンを出さずにトークンを吐き続ける暴走に入ると、処理は @@ -1603,395 +2318,64 @@ export class Worker { const sessionIdentity = await resolveSessionTaskId(this.repo, job); // ── Browser session profile binding ───────────────────────────── - // If this job is bound to a browser_session_profile, decrypt the - // captured Playwright storageState and pass it into runPiece so - // BrowseWeb can inject it into BrowserContext. Owner-mismatch and - // expired-profile checks fail-fast before the agent loop starts. - let browserSessionState: object | undefined; - let browserSessionProfileId: number | undefined; - let browserSessionProfile: - | { loggedInSelector: string | null; loginUrlPatterns: string[] } - | undefined; - let onAuthExpired: - | ((profileId: number, reason: string) => void) - | undefined; + const { browserSessionState, browserSessionProfileId, browserSessionProfile, onAuthExpired } = + this.resolveBrowserSessionBinding(job, localTaskId); - if (job.browserSessionProfileId) { - const sessRepo = new BrowserSessionRepo(this.repo.getDb()); - const profile = sessRepo.getProfileByIdUnsafe(job.browserSessionProfileId); - if (!profile) { - sessRepo.audit({ - actorUserId: job.ownerId ?? null, - ownerId: null, - profileId: job.browserSessionProfileId, - action: 'use', - result: 'error', - reason: 'profile not found', - jobId: job.id, - }); - throw new Error(`Browser session profile ${job.browserSessionProfileId} not found`); - } - // Fail-closed owner check: a job with null/missing ownerId must not - // be allowed to decrypt any profile, even if the profile id would - // otherwise resolve. Helper audits + throws on rejection. Extracted - // to src/engine/browser-session-auth.ts so the contract is unit - // tested in isolation from the Worker class. - assertProfileOwner(profile, job, sessRepo); - if (profile.status !== 'active' || !profile.encryptedStateBlob) { - sessRepo.audit({ - actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: profile.id, - action: 'use', result: 'error', reason: `status=${profile.status}`, jobId: job.id, - }); - throw new Error(`AUTH_SESSION_EXPIRED: profile ${profile.id} status=${profile.status}`); - } - const masterKeyPath = this.config.secrets?.masterKeyPath ?? './data/secrets/master.key'; - // Space-as-Principal P2: space-owned profiles decrypt under the space DEK - // (with owner-DEK fallback for pre-migration blobs); personal under owner DEK. - let stateJson: string; - try { - stateJson = decryptProfileState({ masterKeyPath, sessRepo }, { - ownerId: profile.ownerId, - spaceId: profile.spaceId, - encryptedStateBlob: profile.encryptedStateBlob, - }); - } catch (e) { - sessRepo.audit({ - actorUserId: job.ownerId, - ownerId: profile.ownerId, - profileId: profile.id, - action: 'decrypt', - result: 'error', - reason: `state decrypt failed: ${(e as Error).message}`, - jobId: job.id, - }); - throw e; - } - browserSessionState = JSON.parse(stateJson) as object; - browserSessionProfileId = profile.id; - browserSessionProfile = { - loggedInSelector: profile.loggedInSelector, - loginUrlPatterns: profile.loginUrlPatterns, - }; - onAuthExpired = (pid, reason) => { - sessRepo.markProfileStatus(pid, 'expired', reason); - sessRepo.audit({ - actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: pid, - action: 'expire', result: 'success', reason, jobId: job.id, - }); - // Best-effort task-level notification. Subtask jobs and - // gitea-issue jobs may not have a numeric local_task id. - if (localTaskId !== null) { - this.repo.addLocalTaskComment( - localTaskId, - 'agent', - `⚠️ Browser session "${profile.label}" expired: ${reason}. Re-login from Settings → Browser Sessions.`, - 'progress', - ).catch(() => { /* ignore — comment posting is best-effort */ }); - } - }; - sessRepo.audit({ - actorUserId: job.ownerId, ownerId: profile.ownerId, profileId: profile.id, - action: 'use', result: 'success', jobId: job.id, - }); - sessRepo.touchUsed(profile.id); - } + const { handoffContext, transcriptPath, enrichedInstruction } = + await this.buildInstructionContext(job, workspacePath, localTaskId, runtimeDir); - // Piece handoff: when this job continues an earlier one in the same - // local_task, agent-loop injects a "this is a continuation of piece X" - // block into the system prompt. We resolve the prev piece name + the - // most recent agent result/ask comment as the LLM-visible carry-over. - let handoffContext: import('./engine/agent-loop.js').HandoffContext | undefined; - if (job.continuedFromJobId && isLocalTask && localTaskId !== null) { - const prevJob = await this.repo.getJob(job.continuedFromJobId); - if (prevJob) { - const prevResultComment = await this.repo.getLatestResultComment(localTaskId); - handoffContext = { - prevPiece: prevJob.pieceName, - prevResult: prevResultComment?.body ?? null, - }; - } else { - logger.warn(`[worker:${this.workerId}] continued_from_job_id=${job.continuedFromJobId} not found for job ${jobId}; skipping handoff context`); - } - } + const { mcpDisabled, skillsDisabled } = this.resolveJobPayloadOptions(job); + const userRole = this.resolveJobUserRole(job); + const { workspaceTools, workspaceSshConnections } = await this.resolveWorkspaceToolContext(job, spaceId); - // 会話コンテキストの組み立て。P3: この継続ジョブが transcript を再生する場合 - // (handoffContext あり && 再生可能ターンあり = piece-runner の再生条件と完全一致)、 - // 再生した実ターンと重複するコメント要約ブロックを抑制する。transcriptPath は - // runPiece に渡す runtimeDir から算出するため piece-runner の logsRoot と一致し、 - // 抑制判定と再生判定の split-brain が構造的に起きない。 - const transcriptPath = join(runtimeDir ?? join(workspacePath, 'logs'), 'transcript.jsonl'); - const willReplayTranscript = - handoffContext !== undefined && Conversation.hasReplayableTranscript(transcriptPath); - - let enrichedInstruction = `${buildTimeContextBlock()}${job.instruction}`; - if (isLocalTask) { - try { - const comments = await this.repo.listLocalTaskComments(localTaskId); - const outputFiles = this.listDir(join(workspacePath, 'output')); - const inputFiles = this.listDir(join(workspacePath, 'input')); - const contextBody = buildLocalConversationContext({ - comments, - jobInstruction: job.instruction, - inputFiles, - outputFiles, - omitRecentConversation: willReplayTranscript, - }); - enrichedInstruction = `${buildTimeContextBlock()}${contextBody}`; - } catch (err) { - logger.warn(`[worker:${this.workerId}] failed to build local context: ${err}`); - } - } - const retryHandoffContext = buildRetryHandoffContext(workspacePath, job); - if (retryHandoffContext) { - enrichedInstruction = `${enrichedInstruction}\n\n${retryHandoffContext}`; - } - - // Parse per-task options from job payload (e.g. { options: { mcpDisabled, skillsDisabled } }). - let jobPayloadOptions: Record = {}; - if (job.payload) { - try { - const parsed = JSON.parse(job.payload) as Record; - if (parsed?.options && typeof parsed.options === 'object' && !Array.isArray(parsed.options)) { - jobPayloadOptions = parsed.options as Record; - } - } catch { - logger.warn(`[worker:${this.workerId}] job ${jobId} failed to parse payload JSON`); - } - } - const mcpDisabled = jobPayloadOptions.mcpDisabled === true; - const skillsDisabled = jobPayloadOptions.skillsDisabled === true; - - // Resolve the job owner's role to gate admin-only tool actions - // (e.g. system-scope InstallSkill). Defaults to 'user' when the owner - // is unknown (no-auth mode) or the lookup fails. - let userRole: 'admin' | 'user' = 'user'; - if (job.ownerId) { - try { - const ownerRow = this.repo.getUserById(job.ownerId); - if (ownerRow?.role === 'admin') userRole = 'admin'; - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} owner role lookup failed: ${(err as Error).message}`); - } - } - - // Workspace tool policy: resolve once per job, applied to every movement. - // Uses the already-resolved spaceId (personal-space NULL→real-id handled - // above by resolveToolSpaceId). Fail-closed: any error → safe defaults - // (sensitive tools off). Never throws — a policy error must not crash a job. - let workspaceTools: { allowedTools: string[]; editAllowed: boolean }; - let parsedPolicy: import('./engine/workspace-tool-policy.js').WorkspaceToolPolicy; - try { - const policyJson = spaceId != null ? this.repo.getSpaceToolPolicy(spaceId) : null; - parsedPolicy = parseToolPolicy(policyJson); - workspaceTools = await resolveWorkspaceTools(parsedPolicy); - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} workspace tool policy resolution failed, using safe defaults: ${(err as Error).message}`); - parsedPolicy = {}; - workspaceTools = await resolveWorkspaceTools({}); - } - - // SSH workspace scoping: when the workspace policy enables the 'ssh' category, - // resolve the connection IDs available to this job's space. This space-scoped - // list is the SOLE SSH connection gate (piece-level allowed_ssh_connections was - // removed in PR B), mirroring how MCP uses listEnabledForSpace. spaceId is - // already personal-space-resolved - // (NULL→real-id) by resolveToolSpaceId above, so personal-WS jobs find their - // connections via listBySpace(personalSpaceId). NEVER '*' wildcard — always an - // explicit id list so cross-space isolation is guaranteed. - let workspaceSshConnections: string[] | undefined; - if (parsedPolicy.enabledSensitive?.includes('ssh') && spaceId != null) { - try { - const sshSub = getSshSubsystem(); - if (sshSub !== null) { - workspaceSshConnections = sshSub.connectionRepo.listBySpace(spaceId).map((c) => c.id); - logger.debug(`[worker:${this.workerId}] job ${jobId} ssh workspace connections resolved count=${workspaceSshConnections.length} spaceId=${spaceId}`); - } else { - // SSH subsystem not initialised (e.g. test environment without bridge setup). - // Leave undefined → piece-runner treats it as [] (no connections → SSH rejects). - logger.debug(`[worker:${this.workerId}] job ${jobId} ssh enabled in policy but subsystem not initialised — no connections available`); - } - } catch (err) { - logger.warn(`[worker:${this.workerId}] job ${jobId} ssh connection resolution failed: ${(err as Error).message}`); - // Leave undefined → piece-runner treats it as [] (fail-closed, no connections). - } - } - - const piecePromise = runPiece(piece, enrichedInstruction, llmClient, workspacePath, callbacks, toolsConfig, { - ...pieceOptions, + const piecePromise = this.startPieceRun({ + job, + piece, + enrichedInstruction, + llmClient, + workspacePath, + callbacks, + toolsConfig, + pieceOptions, cancelCheck, - abortController: jobAbortController, - safetyConfig: this.config.safety, - searchFilter: this.config.searchFilter, - customPiecesDir: customPieceDirs, - // Spaces foundation: resolved space folder for AGENTS.md / memory / - // custom-piece resolution. Undefined for gitea-issue jobs => legacy. + jobAbortController, + customPieceDirs, folderContext, - // Spaces foundation: space id for future per-space MCP/SSH resolution. - // Phase 1 no-op (no consumer reads ctx.spaceId yet). spaceId, - // Per-space Python package overlay (admin-installed wheels). Resolve the - // stable `current` symlink path so Bash can bind it read-only + expose it - // on PYTHONPATH. undefined when the feature is disabled; the Bash tool - // also no-ops if the path does not exist yet (nothing installed). - pythonPackagesDir: (() => { - const pk = resolvePythonPackagesConfig(this.config.pythonPackages); - if (!pk.enabled) return undefined; - return spaceCurrentDir(pk.dir, spaceKeyFor(spaceId, sessionIdentity.userId ?? job.ownerId ?? 'local')); - })(), - // Spaces foundation (plan 3): conflict detection for persistent local-task - // workspaces. Undefined (OFF) for ephemeral / gitea-issue jobs => legacy. + sessionIdentity, conflictDetection, - // 計画5: 実行ログ root + 競合台帳 dir。スペースタスクのみ非 undefined。 - // ephemeral / legacy は undefined = workspacePath/logs・workspacePath/.conflict(後方互換)。 runtimeDir, conflictDir, contextManager, - vlmEnabled: workerDef.vlm === true, - jobId, + workerDef, handoffContext, - // When this run IS a subtask, pass parent identity + child workspace - // path so the runner records the lineage in its run-start event. - // Subtask workspaces follow `/subtasks/` where N is the - // subtask job's issueNumber. - parentJobId: isSubTask && parentJobId ? parentJobId : undefined, - childWorkspaceRelative: isSubTask ? `subtasks/${issueNumber}` : undefined, - // Mission Brief: only wire IO when this run is bound to a local - // task (the brief is per-LocalTask, not per-job). Subtask runs - // and gitea-issue runs leave it unset → MissionUpdate degrades - // to a no-op and the system prompt MISSION block is skipped. - missionBrief: isLocalTask && localTaskId !== null && !isSubTask - ? this.repo.makeMissionBriefIO(localTaskId) - : undefined, - // Conversation recall: bind SearchTaskConversation / ReadTaskConversation - // to this local task's comments + this run's transcript. Same gate as - // missionBrief (per-LocalTask, not subtasks/gitea-issue runs) so the - // tools stay scoped and degrade gracefully otherwise. - // - // SECURITY (adversarial review): bind the transcript ONLY to a per-task - // `runtimeDir` (space/{spaceId}/runs/{taskId}). The old - // `runtimeDir ?? workspacePath/logs` fallback is UNSAFE for persistent - // space tasks — workspacePath is the space-SHARED files tree, so a - // NULL-runtime_dir task would expose another (possibly different-user) - // task's transcript in the same space. When runtimeDir is unset we pass - // no transcript path: comment search (always task_id-scoped) still works, - // transcript search simply returns nothing rather than risk a leak. - taskConversation: isLocalTask && localTaskId !== null && !isSubTask - ? this.repo.makeTaskConversationIO( - localTaskId, - runtimeDir ? join(runtimeDir, 'transcript.jsonl') : undefined, - ) - : undefined, - // Workspace-wide task search: same gate as taskConversation, plus an - // owner id (needed to scope the search to the owner's space). In - // no-auth mode job.ownerId is null, so this is undefined and the tool - // degrades to a no-context message rather than searching unscoped. - workspaceTaskSearch: isLocalTask && localTaskId !== null && !isSubTask && job.ownerId - ? this.repo.makeWorkspaceTaskSearchIO(localTaskId, job.ownerId) - : undefined, - // File provenance: bind reads to THIS run's workspace_path (the scoping - // boundary — a shared space workspace is queried only for its own rows, - // never another workspace's / user's lineage) and a recorder bound to - // this task/job/space. Gated on a resolvable local task id (need a - // numeric created_by_task_id). Subtask / gitea-issue runs leave both - // unset → the agent tools degrade to a no-op and Write/Edit/Bash skip - // recording. - fileProvenance: - isLocalTask && localTaskId !== null - ? this.repo.makeFileProvenanceIO(workspacePath) - : undefined, - recordFileProvenance: - isLocalTask && localTaskId !== null - ? (ev) => - this.repo.recordProvenance({ - workspacePath, - relPath: ev.relPath, - event: ev.event, - sourceKind: ev.sourceKind, - checksum: ev.checksum ?? null, - taskId: localTaskId, - jobId, - spaceId: spaceId ?? null, - piece: ev.pieceName, - movement: ev.movementName, - }) - : undefined, - taskId: sessionIdentity.taskId, - userId: sessionIdentity.userId, - // Tool-request mechanism: per-task grant overlay, read fresh each run so - // a resume after inline approval picks up the newly-granted tool. - grantedTools: - isLocalTask && localTaskId !== null ? this.repo.getGrantedTools(String(localTaskId)) : undefined, - // Workspace tool policy: pre-resolved once per job above (fail-closed). - // Drives every movement's effective allowed tools and edit flag. - workspaceTools, - // SSH workspace scoping: connection IDs available to this job's space. - // Undefined when ssh not enabled in policy (movement declaration is fallback). - workspaceSshConnections, - // Pause for inline approval only when a user is reachable: a local task - // that is not itself a subtask (subtasks/headless runs auto-proceed). - toolApprovalInteractive: isLocalTask && !isSubTask, - // Tool-request mechanism: bind the recorder to this run's task/job/space. - // piece + movement are injected upstream by piece-runner. - recordToolRequest: (req) => - this.repo.recordToolRequest({ - ...req, - // Use the local task id (same key as granted_tools + the decide API) - // so the task-detail query and grant overlay line up. - taskId: localTaskId !== null ? String(localTaskId) : null, - jobId, - spaceId: spaceId ?? null, - }), + transcriptPath, + isLocalTask, + localTaskId, + isSubTask, + parentJobId, browserSessionState, browserSessionProfileId, browserSessionProfile, onAuthExpired, - ownerId: job.ownerId, - mcpConfig: mergeMcpConfig(this.config.mcp), - userRole, - skillCatalog: this.skillCatalog ?? undefined, mcpDisabled, skillsDisabled, + userRole, + workspaceTools, + workspaceSshConnections, }); - // ②B deadline hard-kill safety net. Race the piece run against a grace - // timer that arms when the job's abort signal fires (deadline OR cancel). - // ②A wires the abort into the realistic blocking tools so a run normally - // unwinds cooperatively; if it does not within the grace window, hard-kill - // so the inflight slot is freed and the job reaches a terminal state. The - // detached run keeps executing in-process (accepted zombie residual). - // deadlineGraceSeconds <= 0 disables the ②B hard-kill fallback: await the - // run directly and rely on ②A's cooperative abort (matches the documented - // "0 disables" semantics, same convention as maxJobMinutes=0). - const graceSec = this.config.safety?.deadlineGraceSeconds ?? DEFAULT_DEADLINE_GRACE_SECONDS; - const raced = graceSec > 0 - ? await raceWithGrace(piecePromise, jobAbortController.signal, graceSec * 1000) - : { ok: true as const, value: await piecePromise }; - if (!raced.ok) { - logger.warn(`[worker:${this.workerId}] job ${jobId} did not unwind within grace=${graceSec}s after abort; hard-killing to release slot (deadline=${raced.deadline})`); - metricFinalStatus = 'cancelled'; - await this.finalizeHardkill(job, reporter, isSubTask, parentJobId, raced.deadline); - return; - } - const result = raced.value; - logger.info(`[worker:${this.workerId}] job ${jobId} runPiece done: status=${result.status}`); - this.writeRunDiagnostics(workspacePath, result); - - await this.handlePieceResult(result, job, reporter, workspacePath, isLocalTask, isSubTask, parentJobId); - - // Phase 3b: capture the terminal status for the jobs_total label. - // result.status uses piece-runner's own enum - // ('completed'|'aborted'|'error'|'waiting_human'|'waiting_subtasks'|'cancelled'); map to the - // metric enum (waiting_subtasks stays "succeeded" for the metric - // because the job pauses cleanly — not a failure). - switch (result.status) { - case 'completed': metricFinalStatus = 'succeeded'; break; - case 'aborted': metricFinalStatus = 'aborted'; break; - case 'cancelled': metricFinalStatus = 'cancelled'; break; - case 'waiting_human': metricFinalStatus = 'waiting_human'; break; - case 'waiting_subtasks': metricFinalStatus = 'succeeded'; break; - case 'error': - default: metricFinalStatus = 'failed'; break; - } + await this.settlePieceRun({ + piecePromise, + jobAbortController, + job, + reporter, + workspacePath, + isLocalTask, + isSubTask, + parentJobId, + setMetricFinalStatus: (s) => { metricFinalStatus = s; }, + }); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); const errorStack = err instanceof Error && err.stack ? err.stack : '(no stack)'; @@ -2447,6 +2831,21 @@ export class Worker { await this.repo.addAuditLog(jobId, 'job_tool_request', 'worker', { resumeMovement: result.resumeMovement, }); + } else if (result.waitReason === 'package_request') { + // Package-approval pause (RequestPackage). MUST persist + // wait_reason='package_request' so the decide API's + // resumePackageRequestJob can re-queue it — otherwise the job is stuck + // forever. Like tool_request, this is NOT an ASK: it does not consume + // the per-job ASK budget and is not reported as a question. + await this.repo.updateJob(jobId, { + status: 'waiting_human', + resumeMovement: result.resumeMovement ?? null, + waitReason: 'package_request', + }); + this.enqueuePush(job, 'waiting_human'); + await this.repo.addAuditLog(jobId, 'job_package_request', 'worker', { + resumeMovement: result.resumeMovement, + }); } else { await this.repo.updateJob(jobId, { status: 'waiting_human', diff --git a/src/worker/piece-metric-status.test.ts b/src/worker/piece-metric-status.test.ts new file mode 100644 index 0000000..305ba31 --- /dev/null +++ b/src/worker/piece-metric-status.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { mapPieceStatusToMetric } from './piece-metric-status.js'; + +/** + * executeJob(旧: インライン switch)から切り出したメトリクス status 変換の + * ピン留め。src/worker.metrics.test.ts が executeJob の switch をミラーして + * いたのと同じ対応表を、切り出し後の純関数に対して固定する。 + */ +describe('mapPieceStatusToMetric', () => { + it.each([ + ['completed', 'succeeded'], + ['aborted', 'aborted'], + ['cancelled', 'cancelled'], + ['waiting_human', 'waiting_human'], + // waiting_subtasks はクリーンな停車であり失敗ではないため succeeded に寄せる + ['waiting_subtasks', 'succeeded'], + ['error', 'failed'], + ] as const)('maps %s -> %s', (input, expected) => { + expect(mapPieceStatusToMetric(input)).toBe(expected); + }); +}); diff --git a/src/worker/piece-metric-status.ts b/src/worker/piece-metric-status.ts new file mode 100644 index 0000000..1c5d82d --- /dev/null +++ b/src/worker/piece-metric-status.ts @@ -0,0 +1,25 @@ +import type { PieceRunResult } from '../engine/piece-runner.js'; + +/** jobs_total / job_duration_seconds メトリクスの status ラベル値。 */ +export type JobMetricStatus = 'succeeded' | 'failed' | 'aborted' | 'cancelled' | 'waiting_human' | 'error'; + +/** + * Phase 3b: capture the terminal status for the jobs_total label. + * result.status uses piece-runner's own enum + * ('completed'|'aborted'|'error'|'waiting_human'|'waiting_subtasks'|'cancelled'); map to the + * metric enum (waiting_subtasks stays "succeeded" for the metric + * because the job pauses cleanly — not a failure). + * + * worker.ts executeJob 内の switch を純関数として切り出したもの(挙動不変)。 + */ +export function mapPieceStatusToMetric(status: PieceRunResult['status']): JobMetricStatus { + switch (status) { + case 'completed': return 'succeeded'; + case 'aborted': return 'aborted'; + case 'cancelled': return 'cancelled'; + case 'waiting_human': return 'waiting_human'; + case 'waiting_subtasks': return 'succeeded'; + case 'error': + default: return 'failed'; + } +} diff --git a/ui/e2e-auth/sharing-scope.auth.spec.ts b/ui/e2e-auth/sharing-scope.auth.spec.ts index ac8340a..066e4cf 100644 --- a/ui/e2e-auth/sharing-scope.auth.spec.ts +++ b/ui/e2e-auth/sharing-scope.auth.spec.ts @@ -353,19 +353,19 @@ test('invite picker lists same-org member, excludes the stranger', async ({ page expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); -// 4. NO visibility selector. The space chat shows the static visibility NOTE and -// never the removed selector control. -test('space chat shows the static visibility note, not a selector', async ({ page }) => { +// 4. NO visibility control at all. Space-chat visibility is a fixed member-only +// scope, so neither the old selector nor the static note chip should render — +// there is nothing for the user to read or choose. +test('space chat exposes neither a visibility selector nor a visibility note (fixed member-only scope)', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await login(page, MANAGER.email, MANAGER.password); await openSharedSpace(page); await openSeededChat(page); - // The static note is present; the removed selector is absent. - await expect(page.getByTestId('space-chat-visibility-note')).toBeVisible({ timeout: 15_000 }); - await expect(page.getByTestId('space-chat-visibility-note')).toContainText('メンバーに公開'); + // Both the removed selector and the removed static-note chip are absent. await expect(page.getByTestId('space-chat-visibility')).toHaveCount(0); + await expect(page.getByTestId('space-chat-visibility-note')).toHaveCount(0); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); diff --git a/ui/e2e/spaces.spec.ts b/ui/e2e/spaces.spec.ts index a378525..2957f8d 100644 --- a/ui/e2e/spaces.spec.ts +++ b/ui/e2e/spaces.spec.ts @@ -1103,8 +1103,8 @@ test('space MCP isolation: server registered in A is visible in A, absent in B', // Space task/settings feature parity (feat/space-feature-parity-tests). // // Phase 1 added a per-chat action toolbar (data-testid="space-chat-actions") to -// the inline SpaceConversation, with delete / share / continue / visibility -// controls reusing the SAME implementation as the Tasks-page DetailHeader. These +// the inline SpaceConversation, with delete / share / continue controls reusing +// the SAME implementation as the Tasks-page DetailHeader. These // tests prove each action works INSIDE a space without ever bouncing to the // Tasks page, plus the 概要 (overview) feedback flow and every 設定 sub-tab. // diff --git a/ui/src/api.ts b/ui/src/api.ts index 7ee5538..96ff8ae 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,2276 +1,26 @@ -const BASE = '/api'; - -/** - * 4-state representation of a secret field on the wire. See design doc - * 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior → - * Secret Inputs) for the canonical contract. - * - * Phase 1 (this release) keeps the on-wire representation backwards - * compatible with the existing `apiKey: string` shape so that mask- - * preservation in `ConfigManager.updateConfig` keeps working unchanged: - * - * - `unchanged` → serialized as the masked sentinel `'********'` - * - `literal` → serialized as the raw string value - * - `env_ref` → serialized as `${ENV_NAME}` - * - `cleared` → serialized as `''` (empty string) - * - * Phase 2 will switch to a tagged object on the wire and drop the magic - * `'********'` sentinel; the UI form keeps the 4-state union today so the - * Phase 2 migration is a server-side change only. - */ -export type SecretFieldValue = - | { type: 'unchanged' } - | { type: 'literal'; value: string } - | { type: 'env_ref'; env_name: string } - | { type: 'cleared' }; - -/** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */ -export const SECRET_MASKED_SENTINEL = '********'; - -/** - * Parse a stored string secret (as received from `GET /api/config`) into - * the 4-state form used by the UI. The server masks literal secrets to - * `'********'`, so any literal-looking string is treated as `unchanged` - * unless it's an `${ENV_REF}` pattern. Empty / missing values map to - * `cleared`. - */ -export function parseSecretValue(raw: string | null | undefined): SecretFieldValue { - if (raw == null || raw === '') return { type: 'cleared' }; - if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' }; - const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim()); - if (envMatch) return { type: 'env_ref', env_name: envMatch[1] }; - // Anything else came back as plaintext (e.g. fresh UI form not yet - // round-tripped through the server mask) — treat as literal. - return { type: 'literal', value: raw }; -} - -/** - * Serialize a 4-state secret into the string the server currently - * expects. `unchanged` becomes the masked sentinel so server-side - * mask-preservation in `ConfigManager.updateConfig` keeps the existing - * literal in place. - */ -export function serializeSecretValue(v: SecretFieldValue): string { - if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL; - if (v.type === 'literal') return v.value; - if (v.type === 'env_ref') return `\${${v.env_name}}`; - return ''; -} - -export type PieceName = string; // Dynamically loaded from API -export type ProfileName = 'auto' | 'fast' | 'quality'; -export type OutputFormat = 'text' | 'markdown' | 'json'; -export type AskPolicy = 'low' | 'high'; -export type Priority = 'low' | 'medium' | 'high'; -export type Visibility = 'private' | 'org' | 'public'; - -export interface UserOrg { - orgId: string; - orgName: string; - fetchedAt: string; -} - -export async function fetchMyOrgs(): Promise { - const res = await fetch('/api/users/me/orgs'); - if (!res.ok) return []; - const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> }; - return orgs; -} - -export interface SubtaskInfo { - id: string; - issueNumber: number; - status: string; - instruction: string; - worktreePath: string | null; - createdAt: string; - updatedAt: string; - children?: SubtaskInfo[]; - childCount?: number; - childCompleted?: number; -} - -export interface SubtaskActivity { - jobId: string; - issueNumber: number; - status: string; - currentMovement: string | null; - currentActivity: string | null; - activityLog: string; -} - -export type TitleSource = 'auto' | 'agent' | 'user'; - -export interface LocalTask { - id: number; - title: string; - /** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */ - titleSource?: TitleSource; - body: string; - pieceName: string; - profile: string; - outputFormat: string; - askPolicy: string; - priority: string; - state: string; - workspacePath: string | null; - ownerId?: string | null; - ownerName?: string | null; - visibility?: Visibility; - visibilityScopeOrgId?: string | null; - visibilityScopeOrgName?: string | null; - /** Spaces foundation: which space this task belongs to (null = legacy/個人). */ - spaceId?: string | null; - createdAt: string; - updatedAt: string; - latestJob?: { - id: string; - status: string; - waitReason?: string | null; - currentMovement?: string | null; - currentActivity?: string | null; - workerId?: string | null; - /** - * Physical backend id (e.g. LiteLLM deployment) for jobs run through - * a proxy worker. NULL until the proxy has resolved a backend or for - * direct workers entirely. - * Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md. - */ - lastBackendId?: string | null; - contextPromptTokens?: number | null; - contextLimitTokens?: number | null; - contextUpdatedAt?: string | null; - } | null; - subtasks?: SubtaskInfo[]; - subtaskCount?: number; - subtaskCompleted?: number; - feedbackRating?: 'good' | 'bad' | null; - feedbackTags?: string[] | null; - feedbackComment?: string | null; - feedbackAt?: string | null; - shareToken?: string | null; - sharedAt?: string | null; - missionBrief?: MissionBrief | null; -} - -export interface MissionBrief { - goal: string; - done: string; - open: string; - clarifications: string; - user_constraints?: string; - decisions?: string; - current_focus?: string; -} - -export async function updateMissionBrief( - taskId: number, - patch: Partial, -): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } - const data = await res.json(); - return data.missionBrief ?? null; -} - -// ─── Tool-request mechanism ──────────────────────────────────────────────── -export interface ToolRequest { - id: string; - taskId: string | null; - jobId: string | null; - spaceId: string | null; - pieceName: string; - movementName: string; - toolName: string; - reason: string | null; - category: 'requested' | 'blocked' | 'unknown'; - status: 'pending' | 'approved' | 'denied' | 'auto_denied'; - grantScope: 'task' | 'piece' | null; - decidedBy: string | null; - createdAt: string; - decidedAt: string | null; -} - -export async function fetchToolRequests(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests`); - if (!res.ok) throw new Error('failed to fetch tool requests'); - const data = await res.json(); - return (data.toolRequests ?? []) as ToolRequest[]; -} - -export async function decideToolRequest( - taskId: number, - reqId: string, - decision: 'approve' | 'deny', -): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests/${reqId}/decide`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ decision }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } -} - -// Mirrors the API's safe projection (local-files-api.ts): task IDs + source -// kind + piece/movement + timestamps only. Job UUIDs, checksum, and the -// free-text note are intentionally NOT sent to the client (adversarial-review D4). -export interface FileProvenance { - relPath: string; - sourceKind: string; - createdByTaskId: number | null; - createdByPiece: string | null; - createdByMovement: string | null; - firstSeenAt: string | null; - lastModifiedByTaskId: number | null; - lastModifiedAt: string | null; -} - -/** Fetch the provenance record for one workspace file (null when untracked). */ -export async function fetchFileProvenance( - taskId: number, - section: string, - path: string, -): Promise { - const qs = new URLSearchParams({ section, path }); - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/provenance?${qs.toString()}`); - if (!res.ok) return null; - const data = await res.json(); - return (data.provenance ?? null) as FileProvenance | null; -} - -export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection'; - -export interface LocalTaskComment { - id: number; - taskId: number; - author: string; - kind: CommentKind; - body: string; - /** Filenames attached to this comment, saved under the task's input/ dir. */ - attachments?: string[]; - createdAt: string; - injectedAt: string | null; -} - -export interface LocalFileEntry { - name: string; - path: string; - kind: 'directory' | 'file'; - size: number; - modifiedAt: string; -} - -export interface CreateLocalTaskInput { - title?: string; - body: string; - piece: PieceName; - profile: ProfileName; - outputFormat: OutputFormat; - askPolicy: AskPolicy; - priority: Priority; - attachments?: Array<{ name: string; contentBase64: string }>; - visibility?: Visibility; - visibilityScopeOrgId?: string | null; - browserSessionProfileId?: number | null; - /** - * Spaces foundation: 'persistent'(既定)はスペースのワークスペースに蓄積、 - * 'ephemeral' は使い捨て。未指定時はバックエンドが 'persistent' に解決する。 - */ - workspaceMode?: 'persistent' | 'ephemeral'; - /** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */ - spaceId?: string; - options?: { - mcpDisabled?: boolean; - skillsDisabled?: boolean; - }; -} - -export async function fetchLocalTasks(): Promise { - const res = await fetch(`${BASE}/local/tasks`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks'); - return data.tasks ?? []; -} - -export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> { - const res = await fetch(`${BASE}/local/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task'); - return data; -} - -// --- Spaces (個人/案件) --------------------------------------------------- - -export interface Space { - id: string; - kind: 'personal' | 'case'; - title: string; - description: string; - ownerId: string | null; - visibility: 'private' | 'org' | 'public'; - visibilityScopeOrgId: string | null; - status: 'open' | 'archived'; - brandColor: string | null; - workspaceDir: string | null; - createdAt: string; - updatedAt: string; - /** 閲覧者自身のメンバーロール。一覧(GET /spaces)・詳細(GET /spaces/:id)の両方で付与。 - * 非メンバー(根オーナー含む。owner_id はメンバー行ではない)は null。 */ - myRole?: SpaceMemberRole | null; -} - -export async function fetchSpaces(): Promise { - const res = await fetch(`${BASE}/local/spaces`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch spaces'); - return data as Space[]; -} - -export type SpaceMemberRole = 'owner' | 'editor' | 'viewer'; - -export interface SpaceMember { - userId: string; - name: string | null; - email: string | null; - avatarUrl: string | null; - role: SpaceMemberRole; - isOwner: boolean; -} - -export async function fetchSpaceMembers(spaceId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch members'); - return data as SpaceMember[]; -} - -export async function addSpaceMember( - spaceId: string, - input: { userId: string; role: SpaceMemberRole }, -): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to add member'); - } -} - -export async function updateSpaceMemberRole( - spaceId: string, - userId: string, - role: SpaceMemberRole, -): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role }), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to update member role'); - } -} - -export async function removeSpaceMember(spaceId: string, userId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { - method: 'DELETE', - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to remove member'); - } -} - -// ─── ワークスペース招待リンク(再利用トークン)────────────────────── -export type SpaceInviteRole = 'editor' | 'viewer'; - -export interface SpaceInviteInfo { - token: string; - /** 相対パス。origin を前置して共有する。 */ - url: string; - role: SpaceInviteRole; - createdAt: string; - expiresAt: string | null; - revokedAt: string | null; - valid: boolean; -} - -/** 現行の招待リンク(canManageSpace)。無ければ null。 */ -export async function fetchSpaceInvite(spaceId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch invite'); - return (data?.invite ?? null) as SpaceInviteInfo | null; -} - -/** 招待リンクを (再)生成する(canManageSpace)。 */ -export async function createSpaceInvite( - spaceId: string, - input: { role: SpaceInviteRole; expiresInDays?: number | null }, -): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create invite'); - return data.invite as SpaceInviteInfo; -} - -/** 招待リンクを無効化する(canManageSpace)。 */ -export async function revokeSpaceInvite(spaceId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'DELETE' }); - if (!res.ok && res.status !== 204) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to revoke invite'); - } -} - -export interface InvitePreview { - spaceId: string; - spaceTitle: string; - role: SpaceInviteRole; -} - -/** - * 招待トークンのプレビュー。`status` で分岐を呼び出し側に渡す: - * 'ok' → preview / 'unauthorized'(要ログイン)/ 'invalid'(無効・期限切れ・不明)。 - */ -export async function fetchInvitePreview( - token: string, -): Promise<{ status: 'ok'; preview: InvitePreview } | { status: 'unauthorized' | 'invalid' }> { - const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}`); - if (res.status === 401) return { status: 'unauthorized' }; - if (!res.ok) return { status: 'invalid' }; - const data = await res.json(); - return { status: 'ok', preview: data as InvitePreview }; -} - -/** 招待を受諾して参加する。参加先 spaceId を返す。 */ -export async function acceptSpaceInvite(token: string): Promise<{ spaceId: string; alreadyMember: boolean }> { - const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}/accept`, { - method: 'POST', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to accept invite'); - return data as { spaceId: string; alreadyMember: boolean }; -} - -export interface PickableUser { - id: string; - name: string | null; - email: string | null; - avatarUrl: string | null; -} - -export async function fetchPickableUsers(): Promise { - const res = await fetch(`${BASE}/users/pickable`); - if (!res.ok) return []; - return (await res.json()) as PickableUser[]; -} - -// ─── ワークスペース ツールポリシー ──────────────────────────────────────── - -export interface ToolCategory { - name: string; - sensitive: boolean; - enabled: boolean; -} - -export interface SpaceToolPolicy { - disabledSafe: string[]; - enabledSensitive: string[]; -} - -export interface SpaceToolPolicyResponse { - policy: SpaceToolPolicy; - categories: ToolCategory[]; - sensitiveTools: { name: string; enabled: boolean }[]; -} - -export async function fetchSpaceToolPolicy(spaceId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch tool policy'); - return data as SpaceToolPolicyResponse; -} - -export async function updateSpaceToolPolicy( - spaceId: string, - patch: { disabledSafe?: string[]; enabledSensitive?: string[] }, -): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to update tool policy'); - } -} - -// ─── Per-space Python packages ──────────────────────────────────────────── -export interface SpacePythonPackage { - name: string; - spec: string; - addedAt: string; -} -export interface SpacePythonPackagesResponse { - enabled: boolean; - maxPackagesPerSpace: number; - preflight: { ok: boolean; reason?: string }; - packages: SpacePythonPackage[]; -} - -export async function fetchSpacePythonPackages(spaceId: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch python packages'); - return data as SpacePythonPackagesResponse; -} - -export async function addSpacePythonPackage( - spaceId: string, - spec: string, -): Promise<{ packages: SpacePythonPackage[] }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ spec }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to add package'); - return data as { packages: SpacePythonPackage[] }; -} - -export async function removeSpacePythonPackage( - spaceId: string, - name: string, -): Promise<{ packages: SpacePythonPackage[] }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages/${encodeURIComponent(name)}`, { - method: 'DELETE', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to remove package'); - return data as { packages: SpacePythonPackage[] }; -} - -export async function createSpace(input: { - title: string; - description?: string; - brandColor?: string | null; - visibility?: Space['visibility']; -}): Promise { - const res = await fetch(`${BASE}/local/spaces`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create space'); - return data as Space; -} - -export async function updateSpace( - id: string, - patch: Partial>, -): Promise { - const res = await fetch(`${BASE}/local/spaces/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to update space'); - return data as Space; -} - -export async function archiveSpace(id: string): Promise { - const res = await fetch(`${BASE}/local/spaces/${id}/archive`, { method: 'POST' }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d?.error ?? 'Failed to archive space'); - } -} - -export interface PromptCoachAxis { - name: string; - score: number; - comment: string; -} -export interface PromptCoachResult { - overall: number; - axes: PromptCoachAxis[]; - rewrite: string; - predicted_piece: { name: string; reason: string } | null; - maestro_tips: Array<{ feature: string; suggestion: string }>; - personalized: string[]; -} - -/** - * On-demand prompt coach: evaluates a draft task prompt before it is submitted. - * Stateless — nothing is persisted. Returns 503 when the coach is unconfigured. - */ -export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise { - const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt'); - return data as PromptCoachResult; -} - -export async function fetchLocalTask(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task'); - return data.task; -} - -export async function fetchLocalTaskComments(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments'); - return data.comments ?? []; -} - -export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise { - const payload: Record = { body, author }; - if (attachments && attachments.length > 0) payload.attachments = attachments; - const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment'); -} - -export async function updateLocalTask( - taskId: number, - updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null }, -): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task'); - return data.task; -} - -/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */ -export async function regenerateTaskTitle(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title'); - return data.title as string; -} - -export async function continueTaskWithPiece( - taskId: number, - body: { piece: string; instruction: string }, -): Promise<{ jobId: string }> { - const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task'); - return data; -} - -export async function deleteLocalTask(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task'); -} - -export async function cancelLocalTask(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, { - method: 'POST', - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task'); -} - -export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { - const params = new URLSearchParams({ section }); - if (path) params.set('path', path); - const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); - return data; -} - -export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise { - const params = new URLSearchParams({ section, path }); - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error ?? 'Failed to read file'); - } - return await res.text(); -} - -export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { - const params = new URLSearchParams({ section, path }); - return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; -} - -export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { - const params = new URLSearchParams({ section, path, trusted: '1' }); - return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; -} - -// ── Office プレビュー (Excel / PowerPoint) ─────────────────────────────── -// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。 - -export interface OfficeSpreadsheetSheet { - name: string; - rows: string[][]; - rowCount: number; - colCount: number; - truncated: boolean; -} -export interface OfficeSpreadsheetPreview { - kind: 'spreadsheet'; - sheets: OfficeSpreadsheetSheet[]; - truncated: boolean; -} -export interface OfficePresentationPreview { - kind: 'presentation'; - slides: { index: number; dataUrl: string }[]; - slideCount: number; - truncated: boolean; -} -export interface OfficeDocumentPreview { - kind: 'document'; - pages: { index: number; dataUrl: string }[]; - pageCount: number; - truncated: boolean; -} -export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview; - -/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */ -export class OfficePreviewError extends Error { - /** サーバが返した error コード ('converter_unavailable' 等)。 */ - code?: string; - constructor(message: string, code?: string) { - super(message); - this.name = 'OfficePreviewError'; - this.code = code; - } -} - -export async function fetchOfficePreview(url: string): Promise { - const res = await fetch(url); - if (!res.ok) { - const data = await res.json().catch(() => ({} as { error?: string; message?: string })); - throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error); - } - return (await res.json()) as OfficePreview; -} - -export function getLocalFileOfficePreviewUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { - const params = new URLSearchParams({ section, path }); - return `${BASE}/local/tasks/${taskId}/files/office-preview?${params.toString()}`; -} - -// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。 -export type WritableTaskSection = 'input' | 'output'; - -// タスクワークスペースの input/output へファイルをアップロード(複数可)。サーバが -// O_EXCL で衝突回避リネーム + ensurePathWithin で section root に封じ込め。実行中は 409。 -export async function uploadLocalFiles( - taskId: number, - section: WritableTaskSection, - path: string, - files: { name: string; contentBase64: string }[], -): Promise<{ uploaded: { name: string; path: string }[] }> { - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ section, path, files }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files'); - return data as { uploaded: { name: string; path: string }[] }; -} - -// タスクワークスペースの input/output ファイルを削除(複数選択は paths 配列)。サーバ側で -// section root に封じ込め、ファイルのみ対象、存在しないものは冪等スキップ。 -export async function deleteLocalFiles( - taskId: number, - section: WritableTaskSection, - paths: string[], -): Promise<{ deleted: string[]; skipped: string[] }> { - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ section, paths }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files'); - return data as { deleted: string[]; skipped: string[] }; -} - -// Blob を受け取りブラウザのダウンロードを発火する(zip 一括ダウンロード用)。 -function triggerBlobDownload(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} - -// タスクワークスペースの複数ファイルを zip でまとめてダウンロード(全 section 可、read)。 -// サーバが paths を section root に封じ込め、ファイルのみ zip 化。1 件でも zip にする。 -export async function downloadLocalFilesZip( - taskId: number, - section: 'workspace' | 'input' | 'output' | 'logs', - paths: string[], - filename = 'files.zip', -): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/download-zip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ section, paths }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error ?? 'Failed to download files'); - } - triggerBlobDownload(await res.blob(), filename); -} - -// --- Space files (永続ワークスペース {worktreeDir}/space/{id}/files) --- -// タスク版の files API(getLocalFileRawUrl 等)と同形だが、スペース id でキーする。 - -export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { - const params = new URLSearchParams(); - if (path) params.set('path', path); - const qs = params.toString(); - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files${qs ? `?${qs}` : ''}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); - return data; -} - -export async function fetchSpaceFileContent(spaceId: string, path: string): Promise { - const params = new URLSearchParams({ path }); - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/content?${params.toString()}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error ?? 'Failed to read file'); - } - return await res.text(); -} - -export function getSpaceFileRawUrl(spaceId: string, path: string): string { - const params = new URLSearchParams({ path }); - return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`; -} - -export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string { - const params = new URLSearchParams({ path, trusted: '1' }); - return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`; -} - -export function getSpaceFileOfficePreviewUrl(spaceId: string, path: string): string { - const params = new URLSearchParams({ path }); - return `${BASE}/local/spaces/${spaceId}/files/office-preview?${params.toString()}`; -} - -export async function uploadSpaceFiles( - spaceId: string, - path: string, - files: { name: string; contentBase64: string }[], -): Promise<{ uploaded: { name: string; path: string }[] }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path, files }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files'); - return data as { uploaded: { name: string; path: string }[] }; -} - -// スペースのワークスペースファイルを削除する。複数選択は paths(相対パス配列)で渡す。 -// 各パスはサーバ側で spaceFilesDir に封じ込め(traversal は 400)、ファイルのみ対象。 -// 存在しないパスは冪等にスキップされる。 -export async function deleteSpaceFiles( - spaceId: string, - paths: string[], -): Promise<{ deleted: string[]; skipped: string[] }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files'); - return data as { deleted: string[]; skipped: string[] }; -} - -// スペースのワークスペースに空フォルダを作る(owner/editor のみ)。path は親からの -// 相対パス。サーバ側で spaceFilesDir に封じ込め(traversal は 400)。 -export async function createSpaceFolder( - spaceId: string, - path: string, -): Promise<{ created: string }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/mkdir`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create folder'); - return data as { created: string }; -} - -// スペースのファイル/フォルダをリネーム・移動する(owner/editor のみ)。from→to は -// いずれも相対パス完全形。構造ディレクトリ(input/output/logs/apps/readonly)と隠しは -// 不可。衝突時はサーバが `{stem} (N){ext}` に自動リネームし、確定先 path を返す。 -export async function moveSpaceFile( - spaceId: string, - from: string, - to: string, -): Promise<{ from: string; to: string }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/move`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to move'); - return data as { from: string; to: string }; -} - -// スペースの複数ファイルを zip でまとめてダウンロード(read)。サーバが paths を -// spaceFilesDir に封じ込め、ファイルのみ zip 化。1 件でも zip にする。 -export async function downloadSpaceFilesZip( - spaceId: string, - paths: string[], - filename = 'files.zip', -): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/download-zip`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error ?? 'Failed to download files'); - } - triggerBlobDownload(await res.blob(), filename); -} - -// スペースのワークスペースファイルを path 指定で 1 件書き込む(上書き許可)。 -// content(UTF-8 テキスト)または contentBase64(任意バイナリ)のどちらかを渡す。 -// サーバ側で canEditInSpace + ensurePathWithin によりゲートされる。ワークスペース・ -// アプリの postMessage ブリッジ writeFile の代理に使う。 -export async function writeSpaceFile( - spaceId: string, - path: string, - body: { content: string } | { contentBase64: string }, -): Promise<{ path: string; bytes: number }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/write`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path, ...body }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to write file'); - return data as { path: string; bytes: number }; -} - -// --- Space calendar (予定 + 日次集計) --- -// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md - -export interface CalendarEvent { - id: number; - spaceId: string; - ownerId: string | null; - date: string; // 開始日 YYYY-MM-DD(ローカル日付) - endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日 - time: string | null; // 開始 HH:MM。null = 終日 - endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) - title: string; - description: string | null; - createdBy: 'user' | 'agent'; - sourceTaskId: number | null; - createdAt: string; - updatedAt?: string; -} - -export interface CalendarDayCounts { - taskCount: number; - eventCount: number; -} - -export interface CalendarMonth { - days: Record; - events: CalendarEvent[]; -} - -export interface CalendarDayTask { - id: number; - title: string; - state: string; - status: string | null; - createdAt: string; -} - -export interface CalendarDayFile { - name: string; - path: string; - size: number; - mtime: string; -} - -export interface CalendarDay { - tasks: CalendarDayTask[]; - files: CalendarDayFile[]; - events: CalendarEvent[]; -} - -// ── V2: cross-space calendar ────────────────────────────────────────────── -export interface CrossCalendarSpace { - id: string; - name: string; - color: string; // brand_color、無ければ spaceColor(id) で導出済み -} - -export interface CrossCalendarMonth { - // days[date][spaceId] = そのスペースのその日の集計 - days: Record>; - events: CalendarEvent[]; // 各イベントは spaceId を持つ - spaces: CrossCalendarSpace[]; -} - -export async function fetchCrossSpaceCalendarMonth( - month: string, - tzOffset: number, -): Promise { - const params = new URLSearchParams({ month, tz_offset: String(tzOffset) }); - const res = await fetch(`${BASE}/calendar/cross?${params.toString()}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar'); - return data as CrossCalendarMonth; -} - -export async function fetchSpaceCalendarMonth( - spaceId: string, - month: string, - tzOffset: number, -): Promise { - const params = new URLSearchParams({ month, tz_offset: String(tzOffset) }); - const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar?${params.toString()}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar'); - return data as CalendarMonth; -} - -export async function fetchSpaceCalendarDay( - spaceId: string, - date: string, - tzOffset: number, -): Promise { - const params = new URLSearchParams({ date, tz_offset: String(tzOffset) }); - const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/day?${params.toString()}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar day'); - return data as CalendarDay; -} - -export async function createCalendarEvent( - spaceId: string, - input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null }, -): Promise { - const { endDate, endTime, ...rest } = input; - const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create event'); - return data as CalendarEvent; -} - -export async function updateCalendarEvent( - spaceId: string, - eventId: number, - patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }, -): Promise { - const { endDate, endTime, ...rest } = patch; - const body: Record = { ...rest }; - if (endDate !== undefined) body.end_date = endDate; - if (endTime !== undefined) body.end_time = endTime; - const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to update event'); - return data as CalendarEvent; -} - -export async function deleteCalendarEvent(spaceId: string, eventId: number): Promise { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, { - method: 'DELETE', - }); - if (!res.ok && res.status !== 204) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } -} - -export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ section, path, content }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: res.statusText })); - throw new Error(err.error || res.statusText); - } -} - -// --- Config --- -export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record }> { - const res = await fetch(`${BASE}/config`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config'); - return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} }; -} - -export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> { - const res = await fetch(`${BASE}/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'If-Match': etag }, - body: JSON.stringify(config), - }); - const data = await res.json(); - if (res.status === 409) return { ok: false, conflict: true }; - if (!res.ok) throw new Error(data?.error ?? 'Failed to update config'); - return data; -} - -export async function reloadConfig(): Promise { - const res = await fetch(`${BASE}/config/reload`, { method: 'POST' }); - if (!res.ok) throw new Error('Failed to reload config'); -} - -// --- Pieces --- -export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null } -export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] } -export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] } -/** Full response from GET /api/pieces/:name — includes the server-resolved source. */ -export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string } - -/** - * Build a `?key=value&...` query string from defined entries (undefined dropped). - * Used by the space-scoped variants below so passing `spaceId` adds `?spaceId=…` - * and omitting it leaves the per-user URL unchanged (backward compatible). - */ -function buildQuery(params: Record): string { - const parts = Object.entries(params) - .filter(([, v]) => v !== undefined && v !== '') - .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`); - return parts.length ? `?${parts.join('&')}` : ''; -} - -export async function fetchPieces(spaceId?: string): Promise { - const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces'); - return data.pieces; -} - -export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { - const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; - const res = await fetch(url); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece'); - return { piece: data.piece, source: data.source, ownerId: data.ownerId }; -} - -export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { - const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; - const res = await fetch(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece), - }); - if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); } -} - -export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' } - -export async function createPiece(piece: PieceDef, spaceId?: string): Promise { - const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece), - }); - if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); } - const d = await res.json(); - return { source: d.source ?? 'user-custom' }; -} - -export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { - const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; - const res = await fetch(url, { method: 'DELETE' }); - if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); } -} - -// --- Tools --- -/** - * Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by - * `src/bridge/tools-api.ts` (server side). See design doc step 4: - * docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md - */ -export interface ToolCatalogEntry { - name: string; - source: 'builtin' | 'meta' | 'mcp'; - /** - * Coarse grouping for UI. For builtin/meta tools this is a module name - * (e.g. 'core', 'web'). For MCP tools the server uses `mcp:`. - */ - category: string; - /** MCP server id (only set when source === 'mcp'). */ - serverId?: string; - /** Whether the tool can be invoked right now. */ - available: boolean; - /** Human-readable explanation when `available` is false. */ - reason?: string; - /** - * - 'global' → meta tools auto-injected by the agent loop (always available) - * - 'piece' → builtin tool gated by the workspace tool policy (Settings → Tools) - * - 'user' → per-user resource (MCP / SSH) - */ - scope: 'global' | 'piece' | 'user'; -} - -export async function fetchTools(): Promise { - const res = await fetch(`${BASE}/tools`); - if (!res.ok) throw new Error('Failed to fetch tools'); - const data = (await res.json()) as { tools?: unknown }; - if (!Array.isArray(data.tools)) return []; - // Server may still occasionally serve the legacy flat-string shape (e.g. - // during a transient mismatch / proxy / cache). Filter to only well-formed - // catalog entries so the UI never crashes; legacy strings are dropped. - return data.tools.filter( - (t): t is ToolCatalogEntry => - typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string', - ); -} - -// --- Subtasks --- -export interface SubtaskFiles { - files: string[]; - categories: Record; -} - -export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files'); - return { files: data.files ?? [], categories: data.categories ?? {} }; -} - -export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string { - return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`; -} - -export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise { - const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath)); - if (!res.ok) throw new Error('Failed to fetch subtask file content'); - return res.text(); -} - -export async function fetchSubtaskActivities(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); - return data.subtasks ?? []; -} - -export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity'); - return data.activityLog ?? ''; -} - -export async function putFeedback( - taskId: number, - feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string }, -): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(feedback), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback'); - return data.task; -} - -// --- Share --- -export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> { - const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to share task'); - return data; -} - -export async function unshareTask(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task'); -} - -export async function fetchSharedTask(token: string): Promise { - const res = await fetch(`${BASE}/shared/${token}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Not found'); - return data.task; -} - -export async function fetchSharedTaskComments(token: string): Promise { - const res = await fetch(`${BASE}/shared/${token}/comments`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments'); - return data.comments ?? []; -} - -export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { - const params = new URLSearchParams(); - if (path) params.set('path', path); - const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); - return data; -} - -export async function fetchSharedFileContent(token: string, path: string): Promise { - const params = new URLSearchParams({ path }); - const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`); - if (!res.ok) throw new Error('Failed to read file'); - return res.text(); -} - -export function getSharedFileRawUrl(token: string, path: string): string { - const params = new URLSearchParams({ path }); - return `${BASE}/shared/${token}/files/raw?${params.toString()}`; -} - -export async function fetchSharedSubtaskActivities(token: string): Promise { - const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); - return data.subtasks ?? []; -} - -// 個別サブタスク(共有・read-only)。本体の fetchSubtaskActivity / fetchSubtaskFiles の -// 共有版。TaskDataSource(shared) から使う。封じ込めはサーバ側(share-api.ts)。 -export async function fetchSharedSubtaskActivity(token: string, jobId: string): Promise { - const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/activity`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity'); - return data.activityLog ?? ''; -} - -export async function fetchSharedSubtaskFiles(token: string, jobId: string): Promise { - const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files'); - return { files: data.files ?? [], categories: data.categories ?? {} }; -} - -export function sharedSubtaskFileRawUrl(token: string, jobId: string, filePath: string): string { - return `${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files/${filePath}`; -} - -// --- 公開アプリ共有リンク(read-only・認証なし) --- -// スペース内の 1 ワークスペースアプリ(apps/{app}/)をログイン不要の公開トークンで -// read-only 配信する。公開取得(/api/app-share/:token/*)は認証なし、発行/失効 -// (/api/local/spaces/:id/apps/:app/share)は canManageSpace(owner/admin)が必要。 -// サーバ側で apps/{app}/+output/ にパス封じ込め。詳細は src/bridge/app-share-api.ts。 - -// 公開アプリのメタ。entryPath は entry HTML(apps/{app}/index.html 等)。空アプリ等で -// 見つからなければ null になりうる(呼び出し側で 404 表示)。失効/不正トークンは throw。 -export async function fetchAppShareMeta(token: string): Promise<{ appName: string; entryPath: string | null }> { - const res = await fetch(`${BASE}/app-share/${token}`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Not found'); - return data.app; -} - -export async function fetchAppShareFileContent(token: string, path: string): Promise { - const params = new URLSearchParams({ path }); - const res = await fetch(`${BASE}/app-share/${token}/files/content?${params.toString()}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error ?? 'Failed to read file'); - } - return res.text(); -} - -export async function fetchAppShareFiles( - token: string, - dir: string = '', -): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { - const params = new URLSearchParams(); - if (dir) params.set('dir', dir); - const qs = params.toString(); - const res = await fetch(`${BASE}/app-share/${token}/files/list${qs ? `?${qs}` : ''}`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); - return data; -} - -export function getAppShareRawUrl(token: string, path: string): string { - const params = new URLSearchParams({ path }); - return `${BASE}/app-share/${token}/files/raw?${params.toString()}`; -} - -// 発行/失効/取得(canManageSpace)。shareUrl は相対パス(/ui/app/:token)。表示時は -// window.location.origin を前置する。 -export async function createAppShareLink( - spaceId: string, - app: string, -): Promise<{ token: string; shareUrl: string }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, { - method: 'POST', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to create share link'); - return data as { token: string; shareUrl: string }; -} - -export async function getAppShareLink( - spaceId: string, - app: string, -): Promise<{ token: string | null; shareUrl?: string; revokedAt?: string | null }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch share link'); - return data as { token: string | null; shareUrl?: string; revokedAt?: string | null }; -} - -export async function revokeAppShareLink(spaceId: string, app: string): Promise<{ ok: true }> { - const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, { - method: 'DELETE', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to revoke share link'); - return data as { ok: true }; -} - -// --- Browser Session Profiles --- -export interface BrowserSessionProfile { - id: number; - label: string; - startUrl: string; - matchPatterns: string[]; - storageOrigins: string[]; - loggedInSelector: string | null; - loginUrlPatterns: string[]; - status: 'pending' | 'active' | 'expired' | 'revoked' | 'error'; - stateVersion: number; - lastSavedAt: string | null; - lastUsedAt: string | null; - lastValidatedAt: string | null; - lastError: string | null; - createdAt: string; - updatedAt: string; - /** Owning space (null = legacy/personal). Surfaced for space-scoped listings. */ - spaceId?: string | null; - /** The user who created the profile (its DEK owner). */ - ownerId?: string; - /** False when the requesting viewer is NOT the owner: visible but unusable - * (the per-user DEK can only be decrypted by its owner). */ - decryptableByViewer?: boolean; -} - -const SESS_BASE = `${BASE}/browser-sessions`; - -export async function listBrowserSessionProfiles(spaceId?: string): Promise { - const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { credentials: 'same-origin' }); - if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`); - return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles; -} - -export async function createBrowserSessionProfile( - input: Partial & { label: string; startUrl: string }, - spaceId?: string, -): Promise { - const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { ...input, spaceId } : input), - }); - if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`); - return (await r.json() as { profile: BrowserSessionProfile }).profile; -} - -export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> { - const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' }); - if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`); - return r.json(); -} - -export async function saveBrowserSession(id: number, sessionId: string): Promise { - const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }), - }); - if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`); - return (await r.json() as { profile: BrowserSessionProfile }).profile; -} - -export async function cancelBrowserSession(id: number, sessionId: string): Promise { - await fetch(`${SESS_BASE}/profiles/${id}/cancel`, { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }), - }); -} - -export async function testBrowserSessionProfile(id: number): Promise<{ - verdict: { expired: boolean; reason?: string }; - finalUrl: string; - statusCode: number; -}> { - const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' }); - if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`); - return r.json(); -} - -export async function deleteBrowserSessionProfile(id: number, spaceId?: string): Promise { - const r = await fetch(`${SESS_BASE}/profiles/${id}${buildQuery({ spaceId })}`, { method: 'DELETE', credentials: 'same-origin' }); - if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`); -} - -// --- User/space folder files (browser-macros / recordings) --- -export interface FolderFileEntry { name: string; size: number; mtime: string } - -/** - * List files in a writable user-folder subdir. Passing `spaceId` scopes the - * listing to that space's folder (`data/spaces/{id}/{subdir}`); omitting it - * targets the per-user folder. Matches the convention used by the piece/agents - * space-scoped fetchers above. - */ -export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise { - const r = await fetch(`${BASE}/users/me/folder/list${buildQuery({ subdir, spaceId })}`, { credentials: 'same-origin' }); - if (!r.ok) throw new Error(`listFolderFiles: ${r.status}`); - return (await r.json() as { files: FolderFileEntry[] }).files; -} - -/** Read one folder file's text content (space-scoped when `spaceId` is given). */ -export async function getFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise { - const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { credentials: 'same-origin' }); - if (!r.ok) throw new Error(`getFolderFile: ${r.status}`); - return r.text(); -} - -/** Delete one folder file (space-scoped when `spaceId` is given). */ -export async function deleteFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise { - const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { method: 'DELETE', credentials: 'same-origin' }); - if (!r.ok) throw new Error(`deleteFolderFile: ${r.status}`); -} - -// --- Reflection --- -export interface LatestReflectionForTask { - snapshotId: string; - outcome: string; - memoryChanges: number | null; - pieceEdited: boolean; -} - -export async function getLatestReflectionForTask( - taskId: number, -): Promise { - const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`); - const data = await res.json(); - // API returns null body when no reflection exists - if (!data || !data.snapshotId) return null; - return data as LatestReflectionForTask; -} - -// --- User Folder Pets --- -export interface PetSettings { - enabled: boolean; - activePetId: string | null; - size: 32 | 48 | 64 | 80; - position: 'bottom-right'; - sound: boolean; - reducedMotion: boolean; - toolSparkEnabled: boolean; - workerPets: Record; -} - -export interface WorkerInfo { - id: string; - endpoint: string | null; - model: string | null; - roles: string[]; - enabled: boolean; - /** True if this worker fronts an LLM gateway / proxy (Phase A). */ - proxy?: boolean; - /** Proxy implementation; only 'litellm' is currently shipped. */ - proxyType?: 'litellm'; -} - -export async function fetchWorkers(): Promise { - const res = await fetch('/api/workers', { credentials: 'include' }); - if (!res.ok) return []; - const data = await res.json() as { workers?: WorkerInfo[] }; - return data.workers ?? []; -} - -export interface BackendInfo { - id: string; - model: string | null; - online: boolean; -} - -export interface WorkerBackendsResponse { - source: 'direct' | 'proxy'; - proxyType?: 'litellm'; - backends: BackendInfo[]; - /** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */ - error?: string; -} - -export async function fetchWorkerBackends(workerId: string): Promise { - const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, { - credentials: 'include', - }); - if (!res.ok && res.status !== 502) { - // 502 still carries a typed payload from the server. - return { source: 'direct', backends: [], error: `HTTP ${res.status}` }; - } - return await res.json() as WorkerBackendsResponse; -} - -export interface PetSummary { - id: string; - name: string; - description: string | null; - spriteFile: string | null; - previewFile: string | null; - frameWidth: number | null; - frameHeight: number | null; - gridCols: number | null; - gridRows: number | null; - updatedAt: string; -} - -export interface PetDetail extends PetSummary { - manifest: Record; -} - -export interface PetsResponse { - pets: PetSummary[]; - settings: PetSettings; -} - -const PETS_BASE = '/api/users/me/pets'; - -export function petAssetUrl(petId: string, file: string): string { - return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`; -} - -export async function fetchPets(): Promise { - const res = await fetch(PETS_BASE, { credentials: 'include' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets'); - return data; -} - -export async function fetchPet(petId: string): Promise { - const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet'); - return data.pet; -} - -export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise { - const params = new URLSearchParams(); - params.set('filename', file.name); - if (options.petId) params.set('petId', options.petId); - if (options.overwrite) params.set('overwrite', 'true'); - const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/zip' }, - body: await file.arrayBuffer(), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet'); - return data.pet; -} - -export async function deletePet(petId: string): Promise { - const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { - method: 'DELETE', - credentials: 'include', - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet'); -} - -export async function updatePetSettings(patch: Partial): Promise { - const res = await fetch(`${PETS_BASE}/settings`, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings'); - return data.settings; -} - -// ── Side Info Panel ──────────────────────────────────────────────────────── - -export interface NodeStatus { - nodeId: string; - workerId: string; - source: 'direct' | 'proxy'; - online: boolean; - busy: boolean; - busySlots: number; - totalSlots: number; - loadedModel: string | null; - throughputTps: number | null; - lastSeen: string; - lastProbeError?: string; -} - -export interface WorkerStatusBackendRow { - id: string; - state: 'idle' | 'running'; - busySlots: number; - totalSlots: number; - online: boolean | null; -} - -export interface WorkerStatusRow { - id: string; - name: string; - roles: string[]; - state: 'idle' | 'running'; - /** True when this row represents a `proxy: true` worker. */ - proxy: boolean; - /** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */ - busySlots?: number; - totalSlots?: number; - online?: boolean; - /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */ - backends?: WorkerStatusBackendRow[]; - /** - * Occupants of this worker — users whose running jobs use it, each with - * the job kind (`task_kind`: 'agent' = normal, 'reflection' = learning). - * Admin-only — the server only populates this for admins, so non-admin - * clients always receive undefined. - */ - occupants?: Array<{ user: string; kind: string }>; -} - -export async function fetchWorkerStatuses(): Promise { - const res = await fetch('/api/local/dashboard/workers'); - if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`); - return (await res.json()).workers; -} - -/** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */ -export class NodeStatusUnavailableError extends Error { - constructor() { - super('node-status registry not configured'); - this.name = 'NodeStatusUnavailableError'; - } -} - -export async function fetchNodeStatus(): Promise { - const res = await fetch('/api/local/dashboard/node-status'); - if (!res.ok) { - // 503 = registry not configured (e.g. legacy install). Surface as an - // error so the React Query hook can back off polling instead of - // hammering the server every 5s indefinitely. The hook turns this - // into an empty list for rendering. - if (res.status === 503) throw new NodeStatusUnavailableError(); - throw new Error(`Failed to list node status: ${res.status}`); - } - return (await res.json()).nodes; -} - -// ── AAO Gateway: virtual key admin (Phase 2a + 2b) ────────────────────── -// -// Talks to /api/admin/gateway/keys/* — requires admin role. The raw -// bearer key is returned ONCE from POST / and POST /:id/rotate; UI must -// surface it to the user immediately and not expose it again. - -export interface GatewayKey { - id: string; - object: 'gateway.key'; - keyPrefix: string; - team: string; - allowedModels: string[] | null; - source: 'admin' | 'config-import'; - createdAt: string; - createdBy: string | null; - revokedAt: string | null; - revokedBy: string | null; - lastUsedAt: string | null; - tokensBudget: number | null; - rateLimitRpm: number | null; - /** Only present on POST / rotate responses. */ - key?: string; -} - -export interface GatewayKeyUsageResponse { - keyId: string; - currentPeriod: string; - tokensIn: number; - tokensOut: number; - tokensTotal: number; - tokensBudget: number | null; - remaining: number | null; - requestsThisMonth: number; - rateLimitRpm: number | null; - // Phase 3a F9: `rateRecentRequests` removed. The field was always - // null because the admin process doesn't own the gateway's live - // RateLimiter. Phase 3b/3c may re-add it via gateway IPC. - history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>; -} - -export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise { - const q = new URLSearchParams(); - if (params?.team) q.set('team', params.team); - if (params?.activeOnly) q.set('activeOnly', 'true'); - const qs = q.toString(); - const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`); - if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`); - return (await res.json()).keys; -} - -export async function createGatewayKey(input: { - team: string; - allowedModels?: string[]; - tokensBudget?: number | null; - rateLimitRpm?: number | null; -}): Promise { - const res = await fetch(`/api/admin/gateway/keys`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Failed to create gateway key (${res.status}): ${text}`); - } - return res.json(); -} - -export async function getGatewayKey(id: string): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`); - if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`); - return res.json(); -} - -export async function patchGatewayKey( - id: string, - patch: { - tokensBudget?: number | null; - rateLimitRpm?: number | null; - allowedModels?: string[] | null; - }, -): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Failed to update gateway key (${res.status}): ${text}`); - } - return res.json(); -} - -export async function revokeGatewayKey(id: string): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`); - } -} - -export async function rotateGatewayKey(id: string): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`); - } - return res.json(); -} - -export async function deleteGatewayKey(id: string): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Failed to delete gateway key (${res.status}): ${text}`); - } -} - -export async function getGatewayKeyUsage(id: string): Promise { - const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`); - if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`); - return res.json(); -} - -// ============================================================ -// Gateway Server status (Phase 3c) — read-only admin endpoint. -// Drives the Settings → Gateway Server badge and error list. -// ============================================================ -export type GatewayServerState = - | 'unavailable' - | 'disabled' - | 'starting' - | 'running' - | 'stopping' - | 'misconfigured'; - -export interface GatewayServerStatus { - state: GatewayServerState; - /** Desired-enabled flag read from current config. Null when no ConfigManager. */ - enabled: boolean | null; - /** Validation errors that prevented the gateway from starting. */ - errors: string[]; - mounted: boolean; - sharedPort: number; - /** Only present when state==='unavailable'. */ - message?: string; -} - -export async function getGatewayServerStatus(): Promise { - const res = await fetch('/api/admin/gateway/status'); - if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`); - return res.json(); -} - -// ── Skills API ────────────────────────────────────────────────────────── - -export interface SkillSummary { - name: string; - description: string; - triggers: string[]; - source: 'system' | 'user'; - hasDir: boolean; -} - -export interface SkillDetail extends SkillSummary { - /** Body only (frontmatter stripped) — for read-only preview. */ - content: string; - /** Full SKILL.md incl. frontmatter — what the editor must edit/save. */ - raw: string; - files: string[]; - findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>; - maxSeverity: 'high' | 'medium' | 'none'; -} - -export async function fetchSkills(scope?: string, spaceId?: string): Promise { - const res = await fetch(`/api/skills${buildQuery({ scope, spaceId })}`); - if (!res.ok) throw new Error('Failed to fetch skills'); - const data = await res.json(); - return data.skills; -} - -export async function fetchSkillDetail(name: string, scope?: string, spaceId?: string): Promise { - const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`); - if (!res.ok) throw new Error('Skill not found'); - return res.json(); -} - -export async function createSkill(name: string, content: string, scope: string, spaceId?: string): Promise<{ name: string; severity: string; findings: any[] }> { - const res = await fetch(`/api/skills${buildQuery({ spaceId })}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { name, content, scope, spaceId } : { name, content, scope }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(err.error); - } - return res.json(); -} - -export async function updateSkill(name: string, content: string, scope: string, spaceId?: string): Promise { - const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { content, spaceId } : { content }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(err.error); - } - return res.json(); -} - -export async function deleteSkill(name: string, scope: string, spaceId?: string): Promise { - const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { method: 'DELETE' }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(err.error); - } -} - -export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[], spaceId?: string): Promise { - const res = await fetch(`/api/skills/install-from-url${buildQuery({ spaceId })}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(spaceId ? { url, scope, selectedSkills, spaceId } : { url, scope, selectedSkills }), - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(err.error); - } - return res.json(); -} - -// ── Notifications V2 (Web Push) ─────────────────────────────────────── - -export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; - -export interface NotificationPrefsDTO { - userId: string; - enabled: boolean; - events: Record; - includeDetails: boolean; - v1Migrated: boolean; - updatedAt: string; -} - -export interface NotificationPrefsInput { - enabled?: boolean; - events?: Partial>; - includeDetails?: boolean; -} - -export interface PushSubscriptionPublic { - id: string; - endpointHost: string; - userAgent: string | null; - createdAt: string; - lastSuccessAt: string | null; - lastFailureAt: string | null; - failureCount: number; -} - -export interface VapidPublicKeyDTO { - publicKey: string; - keyId: string; -} - -async function notificationsJsonOrThrow(res: Response, fallback: string): Promise { - const data = await res.json().catch(() => ({} as Record)); - if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback); - return data as T; -} - -export async function fetchVapidPublicKey(): Promise { - const res = await fetch(`${BASE}/notifications/vapid-public-key`); - return notificationsJsonOrThrow(res, 'failed to fetch VAPID key'); -} - -export async function listPushSubscriptions(): Promise { - const res = await fetch(`${BASE}/notifications/subscriptions`); - const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>( - res, 'failed to list subscriptions', - ); - return data.subscriptions; -} - -export async function postPushSubscription(input: { - endpoint: string; - p256dh: string; - auth: string; - userAgent?: string; -}): Promise<{ id: string }> { - const res = await fetch(`${BASE}/notifications/subscriptions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return notificationsJsonOrThrow(res, 'failed to register subscription'); -} - -export async function deletePushSubscription(id: string): Promise { - const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' }); - await notificationsJsonOrThrow(res, 'failed to delete subscription'); -} - -export async function fetchNotificationPrefs(): Promise { - const res = await fetch(`${BASE}/notifications/preferences`); - return notificationsJsonOrThrow(res, 'failed to fetch preferences'); -} - -export async function updateNotificationPrefs( - input: NotificationPrefsInput, -): Promise { - const res = await fetch(`${BASE}/notifications/preferences`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - return notificationsJsonOrThrow(res, 'failed to update preferences'); -} - -export async function migrateLocalStoragePrefs( - input: NotificationPrefsInput, -): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> { - const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }); - if (res.status === 409) return { alreadyMigrated: true }; - return notificationsJsonOrThrow(res, 'failed to migrate preferences'); -} - -export async function postTestNotification(): Promise<{ ok: boolean }> { - const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' }); - return notificationsJsonOrThrow(res, 'failed to send test notification'); -} - -// ============================================================ -// LLM usage dashboard v2 (per-user, multi-axis group-by + local timezone). -// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md -export interface UsageCounters { - tokensIn: number; - tokensOut: number; - requests: number; -} -export type UsageGroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; - -/** One time bucket: a per-series-key map of counters. Keys come from `keys`. */ -export interface UsageBucket { - bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM' - segments: Record; -} -export interface UsageByUser extends UsageCounters { - userId: string; - /** Resolved display name (real users); 'local' / 'system' for sentinels. */ - displayName: string; -} -export interface UsageDailyResponse { - from: string; - to: string; - granularity: 'hour' | 'day' | 'week' | 'month'; - groupBy: UsageGroupBy; - tzOffset: number; - scope: 'all' | 'self'; - /** Ordered series keys (legend/palette order). */ - keys: string[]; - /** Human labels for keys (only populated for groupBy=user; else key=label). */ - labels: Record; - series: UsageBucket[]; - totals: Record; - byUser?: UsageByUser[]; // admin / local mode only -} - -export async function getUsageDaily(params: { - from?: string; - to?: string; - granularity?: 'hour' | 'day' | 'week' | 'month'; - groupBy?: UsageGroupBy; - tzOffset?: number; -}): Promise { - const qs = new URLSearchParams(); - if (params.from) qs.set('from', params.from); - if (params.to) qs.set('to', params.to); - if (params.granularity) qs.set('granularity', params.granularity); - if (params.groupBy) qs.set('groupBy', params.groupBy); - if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset)); - const q = qs.toString(); - const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`); - if (!res.ok) throw new Error(`Failed to load usage (${res.status})`); - return res.json(); -} - -// ── Delegate Observability (D0) ────────────────────────────────────────── -// Task 3 API: GET /api/local/tasks/:id/delegate-runs -// GET /api/local/tasks/:id/delegate-runs/:runId/timeline - -/** Lightweight event record returned by the delegate-run timeline endpoint. */ -export interface TraceEventLite { - eventId: string; - ts: string; - seq: number; - kind: string; - movement?: string; - iteration?: number; - payload: unknown; -} - -export async function fetchDelegateRuns(taskId: number): Promise { - const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs`); - if (!res.ok) throw new Error(`fetchDelegateRuns failed: ${res.status}`); - const body = await res.json(); - return { runs: body.runs ?? [], subtasks: body.subtasks ?? [] }; -} - -export async function fetchDelegateRunTimeline(taskId: number, delegateRunId: string, jobId?: string): Promise { - const q = jobId ? `?jobId=${encodeURIComponent(jobId)}` : ''; - const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs/${encodeURIComponent(delegateRunId)}/timeline${q}`); - if (!res.ok) throw new Error(`fetchDelegateRunTimeline failed: ${res.status}`); - return (await res.json()).events ?? []; -} +// ドメイン別モジュール(ui/src/api/*.ts)への分割に伴うバレル。 +// 既存の `import { ... } from '../api'` を全て無変更で通すため、 +// 分割前に export されていた全シンボルをここから再エクスポートする。 +// 注: './api/client'(BASE / buildQuery / triggerBlobDownload)は分割前は +// モジュール内部の非公開ヘルパーだったため、公開面を変えないよう再エクスポートしない。 +export * from './api/config'; +export * from './api/users'; +export * from './api/tasks'; +export * from './api/task-files'; +export * from './api/spaces'; +export * from './api/space-files'; +export * from './api/calendar'; +export * from './api/pieces'; +export * from './api/tools'; +export * from './api/subtasks'; +export * from './api/share'; +export * from './api/browser-sessions'; +export * from './api/folder-files'; +export * from './api/reflection'; +export * from './api/workers'; +export * from './api/pets'; +export * from './api/gateway'; +export * from './api/skills'; +export * from './api/notifications'; +export * from './api/usage'; +export * from './api/delegate'; diff --git a/ui/src/api/browser-sessions.ts b/ui/src/api/browser-sessions.ts new file mode 100644 index 0000000..62f97b6 --- /dev/null +++ b/ui/src/api/browser-sessions.ts @@ -0,0 +1,88 @@ +// api.ts から分割(挙動不変): ブラウザセッションプロファイル。 +import { BASE, buildQuery } from './client'; + +// --- Browser Session Profiles --- +export interface BrowserSessionProfile { + id: number; + label: string; + startUrl: string; + matchPatterns: string[]; + storageOrigins: string[]; + loggedInSelector: string | null; + loginUrlPatterns: string[]; + status: 'pending' | 'active' | 'expired' | 'revoked' | 'error'; + stateVersion: number; + lastSavedAt: string | null; + lastUsedAt: string | null; + lastValidatedAt: string | null; + lastError: string | null; + createdAt: string; + updatedAt: string; + /** Owning space (null = legacy/personal). Surfaced for space-scoped listings. */ + spaceId?: string | null; + /** The user who created the profile (its DEK owner). */ + ownerId?: string; + /** False when the requesting viewer is NOT the owner: visible but unusable + * (the per-user DEK can only be decrypted by its owner). */ + decryptableByViewer?: boolean; +} + +const SESS_BASE = `${BASE}/browser-sessions`; + +export async function listBrowserSessionProfiles(spaceId?: string): Promise { + const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`); + return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles; +} + +export async function createBrowserSessionProfile( + input: Partial & { label: string; startUrl: string }, + spaceId?: string, +): Promise { + const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { ...input, spaceId } : input), + }); + if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`); + return (await r.json() as { profile: BrowserSessionProfile }).profile; +} + +export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> { + const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' }); + if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`); + return r.json(); +} + +export async function saveBrowserSession(id: number, sessionId: string): Promise { + const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }); + if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`); + return (await r.json() as { profile: BrowserSessionProfile }).profile; +} + +export async function cancelBrowserSession(id: number, sessionId: string): Promise { + await fetch(`${SESS_BASE}/profiles/${id}/cancel`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }); +} + +export async function testBrowserSessionProfile(id: number): Promise<{ + verdict: { expired: boolean; reason?: string }; + finalUrl: string; + statusCode: number; +}> { + const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' }); + if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`); + return r.json(); +} + +export async function deleteBrowserSessionProfile(id: number, spaceId?: string): Promise { + const r = await fetch(`${SESS_BASE}/profiles/${id}${buildQuery({ spaceId })}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`); +} diff --git a/ui/src/api/calendar.ts b/ui/src/api/calendar.ts new file mode 100644 index 0000000..fe0135d --- /dev/null +++ b/ui/src/api/calendar.ts @@ -0,0 +1,145 @@ +// api.ts から分割(挙動不変): スペースカレンダー(予定 + 日次集計 + クロススペース)。 +import { BASE } from './client'; + +// --- Space calendar (予定 + 日次集計) --- +// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md + +export interface CalendarEvent { + id: number; + spaceId: string; + ownerId: string | null; + date: string; // 開始日 YYYY-MM-DD(ローカル日付) + endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日 + time: string | null; // 開始 HH:MM。null = 終日 + endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null) + title: string; + description: string | null; + createdBy: 'user' | 'agent'; + sourceTaskId: number | null; + createdAt: string; + updatedAt?: string; +} + +export interface CalendarDayCounts { + taskCount: number; + eventCount: number; +} + +export interface CalendarMonth { + days: Record; + events: CalendarEvent[]; +} + +export interface CalendarDayTask { + id: number; + title: string; + state: string; + status: string | null; + createdAt: string; +} + +export interface CalendarDayFile { + name: string; + path: string; + size: number; + mtime: string; +} + +export interface CalendarDay { + tasks: CalendarDayTask[]; + files: CalendarDayFile[]; + events: CalendarEvent[]; +} + +// ── V2: cross-space calendar ────────────────────────────────────────────── +export interface CrossCalendarSpace { + id: string; + name: string; + color: string; // brand_color、無ければ spaceColor(id) で導出済み +} + +export interface CrossCalendarMonth { + // days[date][spaceId] = そのスペースのその日の集計 + days: Record>; + events: CalendarEvent[]; // 各イベントは spaceId を持つ + spaces: CrossCalendarSpace[]; +} + +export async function fetchCrossSpaceCalendarMonth( + month: string, + tzOffset: number, +): Promise { + const params = new URLSearchParams({ month, tz_offset: String(tzOffset) }); + const res = await fetch(`${BASE}/calendar/cross?${params.toString()}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar'); + return data as CrossCalendarMonth; +} + +export async function fetchSpaceCalendarMonth( + spaceId: string, + month: string, + tzOffset: number, +): Promise { + const params = new URLSearchParams({ month, tz_offset: String(tzOffset) }); + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar?${params.toString()}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar'); + return data as CalendarMonth; +} + +export async function fetchSpaceCalendarDay( + spaceId: string, + date: string, + tzOffset: number, +): Promise { + const params = new URLSearchParams({ date, tz_offset: String(tzOffset) }); + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/day?${params.toString()}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar day'); + return data as CalendarDay; +} + +export async function createCalendarEvent( + spaceId: string, + input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null }, +): Promise { + const { endDate, endTime, ...rest } = input; + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create event'); + return data as CalendarEvent; +} + +export async function updateCalendarEvent( + spaceId: string, + eventId: number, + patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }, +): Promise { + const { endDate, endTime, ...rest } = patch; + const body: Record = { ...rest }; + if (endDate !== undefined) body.end_date = endDate; + if (endTime !== undefined) body.end_time = endTime; + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update event'); + return data as CalendarEvent; +} + +export async function deleteCalendarEvent(spaceId: string, eventId: number): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, { + method: 'DELETE', + }); + if (!res.ok && res.status !== 204) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..6908e0a --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,27 @@ +// api.ts から分割(挙動不変): 全 API モジュール共通の基盤ヘルパー。 + +export const BASE = '/api'; + +// Blob を受け取りブラウザのダウンロードを発火する(zip 一括ダウンロード用)。 +export function triggerBlobDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Build a `?key=value&...` query string from defined entries (undefined dropped). + * Used by the space-scoped variants below so passing `spaceId` adds `?spaceId=…` + * and omitting it leaves the per-user URL unchanged (backward compatible). + */ +export function buildQuery(params: Record): string { + const parts = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`); + return parts.length ? `?${parts.join('&')}` : ''; +} diff --git a/ui/src/api/config.ts b/ui/src/api/config.ts new file mode 100644 index 0000000..07ee637 --- /dev/null +++ b/ui/src/api/config.ts @@ -0,0 +1,84 @@ +// api.ts から分割(挙動不変): Config(設定 API + シークレット4状態表現)。 +import { BASE } from './client'; + +/** + * 4-state representation of a secret field on the wire. See design doc + * 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior → + * Secret Inputs) for the canonical contract. + * + * Phase 1 (this release) keeps the on-wire representation backwards + * compatible with the existing `apiKey: string` shape so that mask- + * preservation in `ConfigManager.updateConfig` keeps working unchanged: + * + * - `unchanged` → serialized as the masked sentinel `'********'` + * - `literal` → serialized as the raw string value + * - `env_ref` → serialized as `${ENV_NAME}` + * - `cleared` → serialized as `''` (empty string) + * + * Phase 2 will switch to a tagged object on the wire and drop the magic + * `'********'` sentinel; the UI form keeps the 4-state union today so the + * Phase 2 migration is a server-side change only. + */ +export type SecretFieldValue = + | { type: 'unchanged' } + | { type: 'literal'; value: string } + | { type: 'env_ref'; env_name: string } + | { type: 'cleared' }; + +/** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */ +export const SECRET_MASKED_SENTINEL = '********'; + +/** + * Parse a stored string secret (as received from `GET /api/config`) into + * the 4-state form used by the UI. The server masks literal secrets to + * `'********'`, so any literal-looking string is treated as `unchanged` + * unless it's an `${ENV_REF}` pattern. Empty / missing values map to + * `cleared`. + */ +export function parseSecretValue(raw: string | null | undefined): SecretFieldValue { + if (raw == null || raw === '') return { type: 'cleared' }; + if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' }; + const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim()); + if (envMatch) return { type: 'env_ref', env_name: envMatch[1] }; + // Anything else came back as plaintext (e.g. fresh UI form not yet + // round-tripped through the server mask) — treat as literal. + return { type: 'literal', value: raw }; +} + +/** + * Serialize a 4-state secret into the string the server currently + * expects. `unchanged` becomes the masked sentinel so server-side + * mask-preservation in `ConfigManager.updateConfig` keeps the existing + * literal in place. + */ +export function serializeSecretValue(v: SecretFieldValue): string { + if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL; + if (v.type === 'literal') return v.value; + if (v.type === 'env_ref') return `\${${v.env_name}}`; + return ''; +} + +// --- Config --- +export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record }> { + const res = await fetch(`${BASE}/config`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config'); + return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} }; +} + +export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> { + const res = await fetch(`${BASE}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'If-Match': etag }, + body: JSON.stringify(config), + }); + const data = await res.json(); + if (res.status === 409) return { ok: false, conflict: true }; + if (!res.ok) throw new Error(data?.error ?? 'Failed to update config'); + return data; +} + +export async function reloadConfig(): Promise { + const res = await fetch(`${BASE}/config/reload`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to reload config'); +} diff --git a/ui/src/api/delegate.ts b/ui/src/api/delegate.ts new file mode 100644 index 0000000..4eedde2 --- /dev/null +++ b/ui/src/api/delegate.ts @@ -0,0 +1,32 @@ +// api.ts から分割(挙動不変): Delegate Observability(D0)。 +// ※ 分割に伴い dynamic type import のパスのみ ./lib → ../lib に補正(同一モジュールを指す)。 +import { BASE } from './client'; + +// ── Delegate Observability (D0) ────────────────────────────────────────── +// Task 3 API: GET /api/local/tasks/:id/delegate-runs +// GET /api/local/tasks/:id/delegate-runs/:runId/timeline + +/** Lightweight event record returned by the delegate-run timeline endpoint. */ +export interface TraceEventLite { + eventId: string; + ts: string; + seq: number; + kind: string; + movement?: string; + iteration?: number; + payload: unknown; +} + +export async function fetchDelegateRuns(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs`); + if (!res.ok) throw new Error(`fetchDelegateRuns failed: ${res.status}`); + const body = await res.json(); + return { runs: body.runs ?? [], subtasks: body.subtasks ?? [] }; +} + +export async function fetchDelegateRunTimeline(taskId: number, delegateRunId: string, jobId?: string): Promise { + const q = jobId ? `?jobId=${encodeURIComponent(jobId)}` : ''; + const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs/${encodeURIComponent(delegateRunId)}/timeline${q}`); + if (!res.ok) throw new Error(`fetchDelegateRunTimeline failed: ${res.status}`); + return (await res.json()).events ?? []; +} diff --git a/ui/src/api/folder-files.ts b/ui/src/api/folder-files.ts new file mode 100644 index 0000000..95df0c9 --- /dev/null +++ b/ui/src/api/folder-files.ts @@ -0,0 +1,30 @@ +// api.ts から分割(挙動不変): ユーザー/スペースフォルダのファイル(browser-macros / recordings)。 +import { BASE, buildQuery } from './client'; + +// --- User/space folder files (browser-macros / recordings) --- +export interface FolderFileEntry { name: string; size: number; mtime: string } + +/** + * List files in a writable user-folder subdir. Passing `spaceId` scopes the + * listing to that space's folder (`data/spaces/{id}/{subdir}`); omitting it + * targets the per-user folder. Matches the convention used by the piece/agents + * space-scoped fetchers above. + */ +export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise { + const r = await fetch(`${BASE}/users/me/folder/list${buildQuery({ subdir, spaceId })}`, { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`listFolderFiles: ${r.status}`); + return (await r.json() as { files: FolderFileEntry[] }).files; +} + +/** Read one folder file's text content (space-scoped when `spaceId` is given). */ +export async function getFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise { + const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`getFolderFile: ${r.status}`); + return r.text(); +} + +/** Delete one folder file (space-scoped when `spaceId` is given). */ +export async function deleteFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise { + const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!r.ok) throw new Error(`deleteFolderFile: ${r.status}`); +} diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts new file mode 100644 index 0000000..d17384f --- /dev/null +++ b/ui/src/api/gateway.ts @@ -0,0 +1,164 @@ +// api.ts から分割(挙動不変): AAO Gateway(virtual key 管理 + サーバ状態)。 + +// ── AAO Gateway: virtual key admin (Phase 2a + 2b) ────────────────────── +// +// Talks to /api/admin/gateway/keys/* — requires admin role. The raw +// bearer key is returned ONCE from POST / and POST /:id/rotate; UI must +// surface it to the user immediately and not expose it again. + +export interface GatewayKey { + id: string; + object: 'gateway.key'; + keyPrefix: string; + team: string; + allowedModels: string[] | null; + source: 'admin' | 'config-import'; + createdAt: string; + createdBy: string | null; + revokedAt: string | null; + revokedBy: string | null; + lastUsedAt: string | null; + tokensBudget: number | null; + rateLimitRpm: number | null; + /** Only present on POST / rotate responses. */ + key?: string; +} + +export interface GatewayKeyUsageResponse { + keyId: string; + currentPeriod: string; + tokensIn: number; + tokensOut: number; + tokensTotal: number; + tokensBudget: number | null; + remaining: number | null; + requestsThisMonth: number; + rateLimitRpm: number | null; + // Phase 3a F9: `rateRecentRequests` removed. The field was always + // null because the admin process doesn't own the gateway's live + // RateLimiter. Phase 3b/3c may re-add it via gateway IPC. + history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>; +} + +export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise { + const q = new URLSearchParams(); + if (params?.team) q.set('team', params.team); + if (params?.activeOnly) q.set('activeOnly', 'true'); + const qs = q.toString(); + const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`); + if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`); + return (await res.json()).keys; +} + +export async function createGatewayKey(input: { + team: string; + allowedModels?: string[]; + tokensBudget?: number | null; + rateLimitRpm?: number | null; +}): Promise { + const res = await fetch(`/api/admin/gateway/keys`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to create gateway key (${res.status}): ${text}`); + } + return res.json(); +} + +export async function getGatewayKey(id: string): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`); + if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`); + return res.json(); +} + +export async function patchGatewayKey( + id: string, + patch: { + tokensBudget?: number | null; + rateLimitRpm?: number | null; + allowedModels?: string[] | null; + }, +): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to update gateway key (${res.status}): ${text}`); + } + return res.json(); +} + +export async function revokeGatewayKey(id: string): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`); + } +} + +export async function rotateGatewayKey(id: string): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`); + } + return res.json(); +} + +export async function deleteGatewayKey(id: string): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to delete gateway key (${res.status}): ${text}`); + } +} + +export async function getGatewayKeyUsage(id: string): Promise { + const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`); + if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`); + return res.json(); +} + +// ============================================================ +// Gateway Server status (Phase 3c) — read-only admin endpoint. +// Drives the Settings → Gateway Server badge and error list. +// ============================================================ +export type GatewayServerState = + | 'unavailable' + | 'disabled' + | 'starting' + | 'running' + | 'stopping' + | 'misconfigured'; + +export interface GatewayServerStatus { + state: GatewayServerState; + /** Desired-enabled flag read from current config. Null when no ConfigManager. */ + enabled: boolean | null; + /** Validation errors that prevented the gateway from starting. */ + errors: string[]; + mounted: boolean; + sharedPort: number; + /** Only present when state==='unavailable'. */ + message?: string; +} + +export async function getGatewayServerStatus(): Promise { + const res = await fetch('/api/admin/gateway/status'); + if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`); + return res.json(); +} diff --git a/ui/src/api/notifications.ts b/ui/src/api/notifications.ts new file mode 100644 index 0000000..20bf2ba --- /dev/null +++ b/ui/src/api/notifications.ts @@ -0,0 +1,107 @@ +// api.ts から分割(挙動不変): 通知 V2(Web Push)。 +import { BASE } from './client'; + +// ── Notifications V2 (Web Push) ─────────────────────────────────────── + +export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human'; + +export interface NotificationPrefsDTO { + userId: string; + enabled: boolean; + events: Record; + includeDetails: boolean; + v1Migrated: boolean; + updatedAt: string; +} + +export interface NotificationPrefsInput { + enabled?: boolean; + events?: Partial>; + includeDetails?: boolean; +} + +export interface PushSubscriptionPublic { + id: string; + endpointHost: string; + userAgent: string | null; + createdAt: string; + lastSuccessAt: string | null; + lastFailureAt: string | null; + failureCount: number; +} + +export interface VapidPublicKeyDTO { + publicKey: string; + keyId: string; +} + +async function notificationsJsonOrThrow(res: Response, fallback: string): Promise { + const data = await res.json().catch(() => ({} as Record)); + if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback); + return data as T; +} + +export async function fetchVapidPublicKey(): Promise { + const res = await fetch(`${BASE}/notifications/vapid-public-key`); + return notificationsJsonOrThrow(res, 'failed to fetch VAPID key'); +} + +export async function listPushSubscriptions(): Promise { + const res = await fetch(`${BASE}/notifications/subscriptions`); + const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>( + res, 'failed to list subscriptions', + ); + return data.subscriptions; +} + +export async function postPushSubscription(input: { + endpoint: string; + p256dh: string; + auth: string; + userAgent?: string; +}): Promise<{ id: string }> { + const res = await fetch(`${BASE}/notifications/subscriptions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return notificationsJsonOrThrow(res, 'failed to register subscription'); +} + +export async function deletePushSubscription(id: string): Promise { + const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' }); + await notificationsJsonOrThrow(res, 'failed to delete subscription'); +} + +export async function fetchNotificationPrefs(): Promise { + const res = await fetch(`${BASE}/notifications/preferences`); + return notificationsJsonOrThrow(res, 'failed to fetch preferences'); +} + +export async function updateNotificationPrefs( + input: NotificationPrefsInput, +): Promise { + const res = await fetch(`${BASE}/notifications/preferences`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + return notificationsJsonOrThrow(res, 'failed to update preferences'); +} + +export async function migrateLocalStoragePrefs( + input: NotificationPrefsInput, +): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> { + const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (res.status === 409) return { alreadyMigrated: true }; + return notificationsJsonOrThrow(res, 'failed to migrate preferences'); +} + +export async function postTestNotification(): Promise<{ ok: boolean }> { + const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' }); + return notificationsJsonOrThrow(res, 'failed to send test notification'); +} diff --git a/ui/src/api/pets.ts b/ui/src/api/pets.ts new file mode 100644 index 0000000..5375d03 --- /dev/null +++ b/ui/src/api/pets.ts @@ -0,0 +1,92 @@ +// api.ts から分割(挙動不変): User Folder Pets。 + +// --- User Folder Pets --- +export interface PetSettings { + enabled: boolean; + activePetId: string | null; + size: 32 | 48 | 64 | 80; + position: 'bottom-right'; + sound: boolean; + reducedMotion: boolean; + toolSparkEnabled: boolean; + workerPets: Record; +} + +export interface PetSummary { + id: string; + name: string; + description: string | null; + spriteFile: string | null; + previewFile: string | null; + frameWidth: number | null; + frameHeight: number | null; + gridCols: number | null; + gridRows: number | null; + updatedAt: string; +} + +export interface PetDetail extends PetSummary { + manifest: Record; +} + +export interface PetsResponse { + pets: PetSummary[]; + settings: PetSettings; +} + +const PETS_BASE = '/api/users/me/pets'; + +export function petAssetUrl(petId: string, file: string): string { + return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`; +} + +export async function fetchPets(): Promise { + const res = await fetch(PETS_BASE, { credentials: 'include' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets'); + return data; +} + +export async function fetchPet(petId: string): Promise { + const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet'); + return data.pet; +} + +export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise { + const params = new URLSearchParams(); + params.set('filename', file.name); + if (options.petId) params.set('petId', options.petId); + if (options.overwrite) params.set('overwrite', 'true'); + const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/zip' }, + body: await file.arrayBuffer(), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet'); + return data.pet; +} + +export async function deletePet(petId: string): Promise { + const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { + method: 'DELETE', + credentials: 'include', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet'); +} + +export async function updatePetSettings(patch: Partial): Promise { + const res = await fetch(`${PETS_BASE}/settings`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings'); + return data.settings; +} diff --git a/ui/src/api/pieces.ts b/ui/src/api/pieces.ts new file mode 100644 index 0000000..5bb3686 --- /dev/null +++ b/ui/src/api/pieces.ts @@ -0,0 +1,53 @@ +// api.ts から分割(挙動不変): Piece CRUD。 +import { BASE, buildQuery } from './client'; + +// --- Pieces --- +export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null } +export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] } +export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] } +/** Full response from GET /api/pieces/:name — includes the server-resolved source. */ +export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string } + +export async function fetchPieces(spaceId?: string): Promise { + const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces'); + return data.pieces; +} + +export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { + const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; + const res = await fetch(url); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece'); + return { piece: data.piece, source: data.source, ownerId: data.ownerId }; +} + +export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { + const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; + const res = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece), + }); + if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); } +} + +export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' } + +export async function createPiece(piece: PieceDef, spaceId?: string): Promise { + const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece), + }); + if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); } + const d = await res.json(); + return { source: d.source ?? 'user-custom' }; +} + +export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise { + const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`; + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); } +} diff --git a/ui/src/api/reflection.ts b/ui/src/api/reflection.ts new file mode 100644 index 0000000..e9261f6 --- /dev/null +++ b/ui/src/api/reflection.ts @@ -0,0 +1,22 @@ +// api.ts から分割(挙動不変): Reflection(タスク別最新スナップショット)。 +import { BASE } from './client'; + +// --- Reflection --- +export interface LatestReflectionForTask { + snapshotId: string; + outcome: string; + memoryChanges: number | null; + pieceEdited: boolean; +} + +export async function getLatestReflectionForTask( + taskId: number, +): Promise { + const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`); + const data = await res.json(); + // API returns null body when no reflection exists + if (!data || !data.snapshotId) return null; + return data as LatestReflectionForTask; +} diff --git a/ui/src/api/share.ts b/ui/src/api/share.ts new file mode 100644 index 0000000..cced8fb --- /dev/null +++ b/ui/src/api/share.ts @@ -0,0 +1,157 @@ +// api.ts から分割(挙動不変): タスク共有リンク + 公開アプリ共有リンク。 +import { BASE } from './client'; +import type { LocalTask, LocalTaskComment, SubtaskActivity } from './tasks'; +import type { LocalFileEntry } from './task-files'; +import type { SubtaskFiles } from './subtasks'; + +// --- Share --- +export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> { + const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to share task'); + return data; +} + +export async function unshareTask(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task'); +} + +export async function fetchSharedTask(token: string): Promise { + const res = await fetch(`${BASE}/shared/${token}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Not found'); + return data.task; +} + +export async function fetchSharedTaskComments(token: string): Promise { + const res = await fetch(`${BASE}/shared/${token}/comments`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments'); + return data.comments ?? []; +} + +export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { + const params = new URLSearchParams(); + if (path) params.set('path', path); + const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); + return data; +} + +export async function fetchSharedFileContent(token: string, path: string): Promise { + const params = new URLSearchParams({ path }); + const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`); + if (!res.ok) throw new Error('Failed to read file'); + return res.text(); +} + +export function getSharedFileRawUrl(token: string, path: string): string { + const params = new URLSearchParams({ path }); + return `${BASE}/shared/${token}/files/raw?${params.toString()}`; +} + +export async function fetchSharedSubtaskActivities(token: string): Promise { + const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); + return data.subtasks ?? []; +} + +// 個別サブタスク(共有・read-only)。本体の fetchSubtaskActivity / fetchSubtaskFiles の +// 共有版。TaskDataSource(shared) から使う。封じ込めはサーバ側(share-api.ts)。 +export async function fetchSharedSubtaskActivity(token: string, jobId: string): Promise { + const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/activity`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity'); + return data.activityLog ?? ''; +} + +export async function fetchSharedSubtaskFiles(token: string, jobId: string): Promise { + const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files'); + return { files: data.files ?? [], categories: data.categories ?? {} }; +} + +export function sharedSubtaskFileRawUrl(token: string, jobId: string, filePath: string): string { + return `${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files/${filePath}`; +} + +// --- 公開アプリ共有リンク(read-only・認証なし) --- +// スペース内の 1 ワークスペースアプリ(apps/{app}/)をログイン不要の公開トークンで +// read-only 配信する。公開取得(/api/app-share/:token/*)は認証なし、発行/失効 +// (/api/local/spaces/:id/apps/:app/share)は canManageSpace(owner/admin)が必要。 +// サーバ側で apps/{app}/+output/ にパス封じ込め。詳細は src/bridge/app-share-api.ts。 + +// 公開アプリのメタ。entryPath は entry HTML(apps/{app}/index.html 等)。空アプリ等で +// 見つからなければ null になりうる(呼び出し側で 404 表示)。失効/不正トークンは throw。 +export async function fetchAppShareMeta(token: string): Promise<{ appName: string; entryPath: string | null }> { + const res = await fetch(`${BASE}/app-share/${token}`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Not found'); + return data.app; +} + +export async function fetchAppShareFileContent(token: string, path: string): Promise { + const params = new URLSearchParams({ path }); + const res = await fetch(`${BASE}/app-share/${token}/files/content?${params.toString()}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to read file'); + } + return res.text(); +} + +export async function fetchAppShareFiles( + token: string, + dir: string = '', +): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { + const params = new URLSearchParams(); + if (dir) params.set('dir', dir); + const qs = params.toString(); + const res = await fetch(`${BASE}/app-share/${token}/files/list${qs ? `?${qs}` : ''}`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); + return data; +} + +export function getAppShareRawUrl(token: string, path: string): string { + const params = new URLSearchParams({ path }); + return `${BASE}/app-share/${token}/files/raw?${params.toString()}`; +} + +// 発行/失効/取得(canManageSpace)。shareUrl は相対パス(/ui/app/:token)。表示時は +// window.location.origin を前置する。 +export async function createAppShareLink( + spaceId: string, + app: string, +): Promise<{ token: string; shareUrl: string }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, { + method: 'POST', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create share link'); + return data as { token: string; shareUrl: string }; +} + +export async function getAppShareLink( + spaceId: string, + app: string, +): Promise<{ token: string | null; shareUrl?: string; revokedAt?: string | null }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch share link'); + return data as { token: string | null; shareUrl?: string; revokedAt?: string | null }; +} + +export async function revokeAppShareLink(spaceId: string, app: string): Promise<{ ok: true }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, { + method: 'DELETE', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to revoke share link'); + return data as { ok: true }; +} diff --git a/ui/src/api/skills.ts b/ui/src/api/skills.ts new file mode 100644 index 0000000..ad6298c --- /dev/null +++ b/ui/src/api/skills.ts @@ -0,0 +1,82 @@ +// api.ts から分割(挙動不変): Skills API。 +import { buildQuery } from './client'; + +// ── Skills API ────────────────────────────────────────────────────────── + +export interface SkillSummary { + name: string; + description: string; + triggers: string[]; + source: 'system' | 'user'; + hasDir: boolean; +} + +export interface SkillDetail extends SkillSummary { + /** Body only (frontmatter stripped) — for read-only preview. */ + content: string; + /** Full SKILL.md incl. frontmatter — what the editor must edit/save. */ + raw: string; + files: string[]; + findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>; + maxSeverity: 'high' | 'medium' | 'none'; +} + +export async function fetchSkills(scope?: string, spaceId?: string): Promise { + const res = await fetch(`/api/skills${buildQuery({ scope, spaceId })}`); + if (!res.ok) throw new Error('Failed to fetch skills'); + const data = await res.json(); + return data.skills; +} + +export async function fetchSkillDetail(name: string, scope?: string, spaceId?: string): Promise { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`); + if (!res.ok) throw new Error('Skill not found'); + return res.json(); +} + +export async function createSkill(name: string, content: string, scope: string, spaceId?: string): Promise<{ name: string; severity: string; findings: any[] }> { + const res = await fetch(`/api/skills${buildQuery({ spaceId })}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { name, content, scope, spaceId } : { name, content, scope }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error); + } + return res.json(); +} + +export async function updateSkill(name: string, content: string, scope: string, spaceId?: string): Promise { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { content, spaceId } : { content }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error); + } + return res.json(); +} + +export async function deleteSkill(name: string, scope: string, spaceId?: string): Promise { + const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error); + } +} + +export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[], spaceId?: string): Promise { + const res = await fetch(`/api/skills/install-from-url${buildQuery({ spaceId })}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spaceId ? { url, scope, selectedSkills, spaceId } : { url, scope, selectedSkills }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(err.error); + } + return res.json(); +} diff --git a/ui/src/api/space-files.ts b/ui/src/api/space-files.ts new file mode 100644 index 0000000..667eb9b --- /dev/null +++ b/ui/src/api/space-files.ts @@ -0,0 +1,145 @@ +// api.ts から分割(挙動不変): スペースの永続ワークスペースファイル。 +import { BASE, triggerBlobDownload } from './client'; +import type { LocalFileEntry } from './task-files'; + +// --- Space files (永続ワークスペース {worktreeDir}/space/{id}/files) --- +// タスク版の files API(getLocalFileRawUrl 等)と同形だが、スペース id でキーする。 + +export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { + const params = new URLSearchParams(); + if (path) params.set('path', path); + const qs = params.toString(); + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files${qs ? `?${qs}` : ''}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); + return data; +} + +export async function fetchSpaceFileContent(spaceId: string, path: string): Promise { + const params = new URLSearchParams({ path }); + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/content?${params.toString()}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to read file'); + } + return await res.text(); +} + +export function getSpaceFileRawUrl(spaceId: string, path: string): string { + const params = new URLSearchParams({ path }); + return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`; +} + +export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string { + const params = new URLSearchParams({ path, trusted: '1' }); + return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`; +} + +export function getSpaceFileOfficePreviewUrl(spaceId: string, path: string): string { + const params = new URLSearchParams({ path }); + return `${BASE}/local/spaces/${spaceId}/files/office-preview?${params.toString()}`; +} + +export async function uploadSpaceFiles( + spaceId: string, + path: string, + files: { name: string; contentBase64: string }[], +): Promise<{ uploaded: { name: string; path: string }[] }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, files }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files'); + return data as { uploaded: { name: string; path: string }[] }; +} + +// スペースのワークスペースファイルを削除する。複数選択は paths(相対パス配列)で渡す。 +// 各パスはサーバ側で spaceFilesDir に封じ込め(traversal は 400)、ファイルのみ対象。 +// 存在しないパスは冪等にスキップされる。 +export async function deleteSpaceFiles( + spaceId: string, + paths: string[], +): Promise<{ deleted: string[]; skipped: string[] }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files'); + return data as { deleted: string[]; skipped: string[] }; +} + +// スペースのワークスペースに空フォルダを作る(owner/editor のみ)。path は親からの +// 相対パス。サーバ側で spaceFilesDir に封じ込め(traversal は 400)。 +export async function createSpaceFolder( + spaceId: string, + path: string, +): Promise<{ created: string }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/mkdir`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create folder'); + return data as { created: string }; +} + +// スペースのファイル/フォルダをリネーム・移動する(owner/editor のみ)。from→to は +// いずれも相対パス完全形。構造ディレクトリ(input/output/logs/apps/readonly)と隠しは +// 不可。衝突時はサーバが `{stem} (N){ext}` に自動リネームし、確定先 path を返す。 +export async function moveSpaceFile( + spaceId: string, + from: string, + to: string, +): Promise<{ from: string; to: string }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from, to }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to move'); + return data as { from: string; to: string }; +} + +// スペースの複数ファイルを zip でまとめてダウンロード(read)。サーバが paths を +// spaceFilesDir に封じ込め、ファイルのみ zip 化。1 件でも zip にする。 +export async function downloadSpaceFilesZip( + spaceId: string, + paths: string[], + filename = 'files.zip', +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/download-zip`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to download files'); + } + triggerBlobDownload(await res.blob(), filename); +} + +// スペースのワークスペースファイルを path 指定で 1 件書き込む(上書き許可)。 +// content(UTF-8 テキスト)または contentBase64(任意バイナリ)のどちらかを渡す。 +// サーバ側で canEditInSpace + ensurePathWithin によりゲートされる。ワークスペース・ +// アプリの postMessage ブリッジ writeFile の代理に使う。 +export async function writeSpaceFile( + spaceId: string, + path: string, + body: { content: string } | { contentBase64: string }, +): Promise<{ path: string; bytes: number }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/write`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, ...body }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to write file'); + return data as { path: string; bytes: number }; +} diff --git a/ui/src/api/spaces.ts b/ui/src/api/spaces.ts new file mode 100644 index 0000000..948b7d0 --- /dev/null +++ b/ui/src/api/spaces.ts @@ -0,0 +1,290 @@ +// api.ts から分割(挙動不変): スペース(CRUD・メンバー・招待・ツールポリシー・Python パッケージ)。 +import { BASE } from './client'; + +// --- Spaces (個人/案件) --------------------------------------------------- + +export interface Space { + id: string; + kind: 'personal' | 'case'; + title: string; + description: string; + ownerId: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + status: 'open' | 'archived'; + brandColor: string | null; + workspaceDir: string | null; + createdAt: string; + updatedAt: string; + /** 閲覧者自身のメンバーロール。一覧(GET /spaces)・詳細(GET /spaces/:id)の両方で付与。 + * 非メンバー(根オーナー含む。owner_id はメンバー行ではない)は null。 */ + myRole?: SpaceMemberRole | null; +} + +export async function fetchSpaces(): Promise { + const res = await fetch(`${BASE}/local/spaces`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch spaces'); + return data as Space[]; +} + +export type SpaceMemberRole = 'owner' | 'editor' | 'viewer'; + +export interface SpaceMember { + userId: string; + name: string | null; + email: string | null; + avatarUrl: string | null; + role: SpaceMemberRole; + isOwner: boolean; +} + +export async function fetchSpaceMembers(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch members'); + return data as SpaceMember[]; +} + +export async function addSpaceMember( + spaceId: string, + input: { userId: string; role: SpaceMemberRole }, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to add member'); + } +} + +export async function updateSpaceMemberRole( + spaceId: string, + userId: string, + role: SpaceMemberRole, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to update member role'); + } +} + +export async function removeSpaceMember(spaceId: string, userId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to remove member'); + } +} + +// ─── ワークスペース招待リンク(再利用トークン)────────────────────── +export type SpaceInviteRole = 'editor' | 'viewer'; + +export interface SpaceInviteInfo { + token: string; + /** 相対パス。origin を前置して共有する。 */ + url: string; + role: SpaceInviteRole; + createdAt: string; + expiresAt: string | null; + revokedAt: string | null; + valid: boolean; +} + +/** 現行の招待リンク(canManageSpace)。無ければ null。 */ +export async function fetchSpaceInvite(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch invite'); + return (data?.invite ?? null) as SpaceInviteInfo | null; +} + +/** 招待リンクを (再)生成する(canManageSpace)。 */ +export async function createSpaceInvite( + spaceId: string, + input: { role: SpaceInviteRole; expiresInDays?: number | null }, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create invite'); + return data.invite as SpaceInviteInfo; +} + +/** 招待リンクを無効化する(canManageSpace)。 */ +export async function revokeSpaceInvite(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'DELETE' }); + if (!res.ok && res.status !== 204) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to revoke invite'); + } +} + +export interface InvitePreview { + spaceId: string; + spaceTitle: string; + role: SpaceInviteRole; +} + +/** + * 招待トークンのプレビュー。`status` で分岐を呼び出し側に渡す: + * 'ok' → preview / 'unauthorized'(要ログイン)/ 'invalid'(無効・期限切れ・不明)。 + */ +export async function fetchInvitePreview( + token: string, +): Promise<{ status: 'ok'; preview: InvitePreview } | { status: 'unauthorized' | 'invalid' }> { + const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}`); + if (res.status === 401) return { status: 'unauthorized' }; + if (!res.ok) return { status: 'invalid' }; + const data = await res.json(); + return { status: 'ok', preview: data as InvitePreview }; +} + +/** 招待を受諾して参加する。参加先 spaceId を返す。 */ +export async function acceptSpaceInvite(token: string): Promise<{ spaceId: string; alreadyMember: boolean }> { + const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}/accept`, { + method: 'POST', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to accept invite'); + return data as { spaceId: string; alreadyMember: boolean }; +} + + +// ─── ワークスペース ツールポリシー ──────────────────────────────────────── + +export interface ToolCategory { + name: string; + sensitive: boolean; + enabled: boolean; +} + +export interface SpaceToolPolicy { + disabledSafe: string[]; + enabledSensitive: string[]; +} + +export interface SpaceToolPolicyResponse { + policy: SpaceToolPolicy; + categories: ToolCategory[]; + sensitiveTools: { name: string; enabled: boolean }[]; +} + +export async function fetchSpaceToolPolicy(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch tool policy'); + return data as SpaceToolPolicyResponse; +} + +export async function updateSpaceToolPolicy( + spaceId: string, + patch: { disabledSafe?: string[]; enabledSensitive?: string[] }, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to update tool policy'); + } +} + +// ─── Per-space Python packages ──────────────────────────────────────────── +export interface SpacePythonPackage { + name: string; + spec: string; + addedAt: string; +} +export interface SpacePythonPackagesResponse { + enabled: boolean; + maxPackagesPerSpace: number; + preflight: { ok: boolean; reason?: string }; + packages: SpacePythonPackage[]; +} + +export async function fetchSpacePythonPackages(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch python packages'); + return data as SpacePythonPackagesResponse; +} + +export async function addSpacePythonPackage( + spaceId: string, + spec: string, +): Promise<{ packages: SpacePythonPackage[] }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spec }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to add package'); + return data as { packages: SpacePythonPackage[] }; +} + +export async function removeSpacePythonPackage( + spaceId: string, + name: string, +): Promise<{ packages: SpacePythonPackage[] }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/python-packages/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to remove package'); + return data as { packages: SpacePythonPackage[] }; +} + +export async function createSpace(input: { + title: string; + description?: string; + brandColor?: string | null; + visibility?: Space['visibility']; +}): Promise { + const res = await fetch(`${BASE}/local/spaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create space'); + return data as Space; +} + +export async function updateSpace( + id: string, + patch: Partial>, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update space'); + return data as Space; +} + +export async function archiveSpace(id: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${id}/archive`, { method: 'POST' }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to archive space'); + } +} diff --git a/ui/src/api/subtasks.ts b/ui/src/api/subtasks.ts new file mode 100644 index 0000000..08f294d --- /dev/null +++ b/ui/src/api/subtasks.ts @@ -0,0 +1,40 @@ +// api.ts から分割(挙動不変): サブタスク(ファイル・進捗)。 +import { BASE } from './client'; +import type { SubtaskActivity } from './tasks'; + +// --- Subtasks --- +export interface SubtaskFiles { + files: string[]; + categories: Record; +} + +export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files'); + return { files: data.files ?? [], categories: data.categories ?? {} }; +} + +export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string { + return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`; +} + +export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise { + const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath)); + if (!res.ok) throw new Error('Failed to fetch subtask file content'); + return res.text(); +} + +export async function fetchSubtaskActivities(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities'); + return data.subtasks ?? []; +} + +export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity'); + return data.activityLog ?? ''; +} diff --git a/ui/src/api/task-files.ts b/ui/src/api/task-files.ts new file mode 100644 index 0000000..0e83045 --- /dev/null +++ b/ui/src/api/task-files.ts @@ -0,0 +1,191 @@ +// api.ts から分割(挙動不変): タスクワークスペースのファイル(一覧・内容・Office プレビュー・アップロード/削除/zip)。 +import { BASE, triggerBlobDownload } from './client'; + +// Mirrors the API's safe projection (local-files-api.ts): task IDs + source +// kind + piece/movement + timestamps only. Job UUIDs, checksum, and the +// free-text note are intentionally NOT sent to the client (adversarial-review D4). +export interface FileProvenance { + relPath: string; + sourceKind: string; + createdByTaskId: number | null; + createdByPiece: string | null; + createdByMovement: string | null; + firstSeenAt: string | null; + lastModifiedByTaskId: number | null; + lastModifiedAt: string | null; +} + +/** Fetch the provenance record for one workspace file (null when untracked). */ +export async function fetchFileProvenance( + taskId: number, + section: string, + path: string, +): Promise { + const qs = new URLSearchParams({ section, path }); + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/provenance?${qs.toString()}`); + if (!res.ok) return null; + const data = await res.json(); + return (data.provenance ?? null) as FileProvenance | null; +} + +export interface LocalFileEntry { + name: string; + path: string; + kind: 'directory' | 'file'; + size: number; + modifiedAt: string; +} + +export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { + const params = new URLSearchParams({ section }); + if (path) params.set('path', path); + const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to list files'); + return data; +} + +export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise { + const params = new URLSearchParams({ section, path }); + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to read file'); + } + return await res.text(); +} + +export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { + const params = new URLSearchParams({ section, path }); + return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; +} + +export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { + const params = new URLSearchParams({ section, path, trusted: '1' }); + return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`; +} + +// ── Office プレビュー (Excel / PowerPoint) ─────────────────────────────── +// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。 + +export interface OfficeSpreadsheetSheet { + name: string; + rows: string[][]; + rowCount: number; + colCount: number; + truncated: boolean; +} +export interface OfficeSpreadsheetPreview { + kind: 'spreadsheet'; + sheets: OfficeSpreadsheetSheet[]; + truncated: boolean; +} +export interface OfficePresentationPreview { + kind: 'presentation'; + slides: { index: number; dataUrl: string }[]; + slideCount: number; + truncated: boolean; +} +export interface OfficeDocumentPreview { + kind: 'document'; + pages: { index: number; dataUrl: string }[]; + pageCount: number; + truncated: boolean; +} +export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview; + +/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */ +export class OfficePreviewError extends Error { + /** サーバが返した error コード ('converter_unavailable' 等)。 */ + code?: string; + constructor(message: string, code?: string) { + super(message); + this.name = 'OfficePreviewError'; + this.code = code; + } +} + +export async function fetchOfficePreview(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const data = await res.json().catch(() => ({} as { error?: string; message?: string })); + throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error); + } + return (await res.json()) as OfficePreview; +} + +export function getLocalFileOfficePreviewUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string { + const params = new URLSearchParams({ section, path }); + return `${BASE}/local/tasks/${taskId}/files/office-preview?${params.toString()}`; +} + +// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。 +export type WritableTaskSection = 'input' | 'output'; + +// タスクワークスペースの input/output へファイルをアップロード(複数可)。サーバが +// O_EXCL で衝突回避リネーム + ensurePathWithin で section root に封じ込め。実行中は 409。 +export async function uploadLocalFiles( + taskId: number, + section: WritableTaskSection, + path: string, + files: { name: string; contentBase64: string }[], +): Promise<{ uploaded: { name: string; path: string }[] }> { + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/upload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, path, files }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files'); + return data as { uploaded: { name: string; path: string }[] }; +} + +// タスクワークスペースの input/output ファイルを削除(複数選択は paths 配列)。サーバ側で +// section root に封じ込め、ファイルのみ対象、存在しないものは冪等スキップ。 +export async function deleteLocalFiles( + taskId: number, + section: WritableTaskSection, + paths: string[], +): Promise<{ deleted: string[]; skipped: string[] }> { + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, paths }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files'); + return data as { deleted: string[]; skipped: string[] }; +} + + +// タスクワークスペースの複数ファイルを zip でまとめてダウンロード(全 section 可、read)。 +// サーバが paths を section root に封じ込め、ファイルのみ zip 化。1 件でも zip にする。 +export async function downloadLocalFilesZip( + taskId: number, + section: 'workspace' | 'input' | 'output' | 'logs', + paths: string[], + filename = 'files.zip', +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/download-zip`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, paths }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to download files'); + } + triggerBlobDownload(await res.blob(), filename); +} + +export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, path, content }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} diff --git a/ui/src/api/tasks.ts b/ui/src/api/tasks.ts new file mode 100644 index 0000000..bce80e5 --- /dev/null +++ b/ui/src/api/tasks.ts @@ -0,0 +1,369 @@ +// api.ts から分割(挙動不変): ローカルタスク(CRUD・コメント・ツール要求・プロンプトコーチ・フィードバック)。 +import { BASE } from './client'; + +export type PieceName = string; // Dynamically loaded from API +export type ProfileName = 'auto' | 'fast' | 'quality'; +export type OutputFormat = 'text' | 'markdown' | 'json'; +export type AskPolicy = 'low' | 'high'; +export type Priority = 'low' | 'medium' | 'high'; +export type Visibility = 'private' | 'org' | 'public'; + +export interface SubtaskInfo { + id: string; + issueNumber: number; + status: string; + instruction: string; + worktreePath: string | null; + createdAt: string; + updatedAt: string; + children?: SubtaskInfo[]; + childCount?: number; + childCompleted?: number; +} + +export interface SubtaskActivity { + jobId: string; + issueNumber: number; + status: string; + currentMovement: string | null; + currentActivity: string | null; + activityLog: string; +} + +export type TitleSource = 'auto' | 'agent' | 'user'; + +export interface LocalTask { + id: number; + title: string; + /** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */ + titleSource?: TitleSource; + body: string; + pieceName: string; + profile: string; + outputFormat: string; + askPolicy: string; + priority: string; + state: string; + workspacePath: string | null; + ownerId?: string | null; + ownerName?: string | null; + visibility?: Visibility; + visibilityScopeOrgId?: string | null; + visibilityScopeOrgName?: string | null; + /** Spaces foundation: which space this task belongs to (null = legacy/個人). */ + spaceId?: string | null; + createdAt: string; + updatedAt: string; + latestJob?: { + id: string; + status: string; + waitReason?: string | null; + currentMovement?: string | null; + currentActivity?: string | null; + workerId?: string | null; + /** + * Physical backend id (e.g. LiteLLM deployment) for jobs run through + * a proxy worker. NULL until the proxy has resolved a backend or for + * direct workers entirely. + * Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md. + */ + lastBackendId?: string | null; + contextPromptTokens?: number | null; + contextLimitTokens?: number | null; + contextUpdatedAt?: string | null; + } | null; + subtasks?: SubtaskInfo[]; + subtaskCount?: number; + subtaskCompleted?: number; + feedbackRating?: 'good' | 'bad' | null; + feedbackTags?: string[] | null; + feedbackComment?: string | null; + feedbackAt?: string | null; + shareToken?: string | null; + sharedAt?: string | null; + missionBrief?: MissionBrief | null; +} + +export interface MissionBrief { + goal: string; + done: string; + open: string; + clarifications: string; + user_constraints?: string; + decisions?: string; + current_focus?: string; +} + +export async function updateMissionBrief( + taskId: number, + patch: Partial, +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + const data = await res.json(); + return data.missionBrief ?? null; +} + +// ─── Tool-request mechanism ──────────────────────────────────────────────── +export interface ToolRequest { + id: string; + taskId: string | null; + jobId: string | null; + spaceId: string | null; + pieceName: string; + movementName: string; + toolName: string; + reason: string | null; + category: 'requested' | 'blocked' | 'unknown'; + status: 'pending' | 'approved' | 'denied' | 'auto_denied'; + grantScope: 'task' | 'piece' | null; + decidedBy: string | null; + createdAt: string; + decidedAt: string | null; +} + +export async function fetchToolRequests(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests`); + if (!res.ok) throw new Error('failed to fetch tool requests'); + const data = await res.json(); + return (data.toolRequests ?? []) as ToolRequest[]; +} + +export async function decideToolRequest( + taskId: number, + reqId: string, + decision: 'approve' | 'deny', +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests/${reqId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} + +// Package-request mechanism (PR3): agent-declared Python package requests. +export interface PackageRequest { + id: string; + taskId: string | null; + jobId: string | null; + spaceId: string | null; + pieceName: string | null; + movementName: string | null; + spec: string; + normalizedName: string; + reason: string | null; + status: 'pending' | 'approved' | 'denied' | 'auto_denied'; + decidedBy: string | null; + createdAt: string; + decidedAt: string | null; +} + +export async function fetchPackageRequests(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/package-requests`); + if (!res.ok) throw new Error('failed to fetch package requests'); + const data = await res.json(); + return (data.packageRequests ?? []) as PackageRequest[]; +} + +export async function decidePackageRequest( + taskId: number, + reqId: string, + decision: 'approve' | 'deny', +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/package-requests/${reqId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decision }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } +} + +export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection'; + +export interface LocalTaskComment { + id: number; + taskId: number; + author: string; + kind: CommentKind; + body: string; + /** Filenames attached to this comment, saved under the task's input/ dir. */ + attachments?: string[]; + createdAt: string; + injectedAt: string | null; +} + +export interface CreateLocalTaskInput { + title?: string; + body: string; + piece: PieceName; + profile: ProfileName; + outputFormat: OutputFormat; + askPolicy: AskPolicy; + priority: Priority; + attachments?: Array<{ name: string; contentBase64: string }>; + visibility?: Visibility; + visibilityScopeOrgId?: string | null; + browserSessionProfileId?: number | null; + /** + * Spaces foundation: 'persistent'(既定)はスペースのワークスペースに蓄積、 + * 'ephemeral' は使い捨て。未指定時はバックエンドが 'persistent' に解決する。 + */ + workspaceMode?: 'persistent' | 'ephemeral'; + /** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */ + spaceId?: string; + options?: { + mcpDisabled?: boolean; + skillsDisabled?: boolean; + }; +} + +export async function fetchLocalTasks(): Promise { + const res = await fetch(`${BASE}/local/tasks`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks'); + return data.tasks ?? []; +} + +export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> { + const res = await fetch(`${BASE}/local/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task'); + return data; +} + +export interface PromptCoachAxis { + name: string; + score: number; + comment: string; +} +export interface PromptCoachResult { + overall: number; + axes: PromptCoachAxis[]; + rewrite: string; + predicted_piece: { name: string; reason: string } | null; + maestro_tips: Array<{ feature: string; suggestion: string }>; + personalized: string[]; +} + +/** + * On-demand prompt coach: evaluates a draft task prompt before it is submitted. + * Stateless — nothing is persisted. Returns 503 when the coach is unconfigured. + */ +export async function evaluatePrompt(input: { instruction: string; piece?: string }): Promise { + const res = await fetch(`${BASE}/local/tasks/evaluate-prompt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to evaluate prompt'); + return data as PromptCoachResult; +} + +export async function fetchLocalTask(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task'); + return data.task; +} + +export async function fetchLocalTaskComments(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments'); + return data.comments ?? []; +} + +export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise { + const payload: Record = { body, author }; + if (attachments && attachments.length > 0) payload.attachments = attachments; + const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment'); +} + +export async function updateLocalTask( + taskId: number, + updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null }, +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task'); + return data.task; +} + +/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */ +export async function regenerateTaskTitle(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title'); + return data.title as string; +} + +export async function continueTaskWithPiece( + taskId: number, + body: { piece: string; instruction: string }, +): Promise<{ jobId: string }> { + const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task'); + return data; +} + +export async function deleteLocalTask(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task'); +} + +export async function cancelLocalTask(taskId: number): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, { + method: 'POST', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task'); +} + +export async function putFeedback( + taskId: number, + feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string }, +): Promise { + const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(feedback), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback'); + return data.task; +} diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts new file mode 100644 index 0000000..7b9cb66 --- /dev/null +++ b/ui/src/api/tools.ts @@ -0,0 +1,44 @@ +// api.ts から分割(挙動不変): ツールカタログ。 +import { BASE } from './client'; + +// --- Tools --- +/** + * Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by + * `src/bridge/tools-api.ts` (server side). See design doc step 4: + * docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md + */ +export interface ToolCatalogEntry { + name: string; + source: 'builtin' | 'meta' | 'mcp'; + /** + * Coarse grouping for UI. For builtin/meta tools this is a module name + * (e.g. 'core', 'web'). For MCP tools the server uses `mcp:`. + */ + category: string; + /** MCP server id (only set when source === 'mcp'). */ + serverId?: string; + /** Whether the tool can be invoked right now. */ + available: boolean; + /** Human-readable explanation when `available` is false. */ + reason?: string; + /** + * - 'global' → meta tools auto-injected by the agent loop (always available) + * - 'piece' → builtin tool gated by the workspace tool policy (Settings → Tools) + * - 'user' → per-user resource (MCP / SSH) + */ + scope: 'global' | 'piece' | 'user'; +} + +export async function fetchTools(): Promise { + const res = await fetch(`${BASE}/tools`); + if (!res.ok) throw new Error('Failed to fetch tools'); + const data = (await res.json()) as { tools?: unknown }; + if (!Array.isArray(data.tools)) return []; + // Server may still occasionally serve the legacy flat-string shape (e.g. + // during a transient mismatch / proxy / cache). Filter to only well-formed + // catalog entries so the UI never crashes; legacy strings are dropped. + return data.tools.filter( + (t): t is ToolCatalogEntry => + typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string', + ); +} diff --git a/ui/src/api/usage.ts b/ui/src/api/usage.ts new file mode 100644 index 0000000..a5bfbbd --- /dev/null +++ b/ui/src/api/usage.ts @@ -0,0 +1,57 @@ +// api.ts から分割(挙動不変): LLM usage ダッシュボード v2。 +import { BASE } from './client'; + +// ============================================================ +// LLM usage dashboard v2 (per-user, multi-axis group-by + local timezone). +// Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md +export interface UsageCounters { + tokensIn: number; + tokensOut: number; + requests: number; +} +export type UsageGroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; + +/** One time bucket: a per-series-key map of counters. Keys come from `keys`. */ +export interface UsageBucket { + bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM' + segments: Record; +} +export interface UsageByUser extends UsageCounters { + userId: string; + /** Resolved display name (real users); 'local' / 'system' for sentinels. */ + displayName: string; +} +export interface UsageDailyResponse { + from: string; + to: string; + granularity: 'hour' | 'day' | 'week' | 'month'; + groupBy: UsageGroupBy; + tzOffset: number; + scope: 'all' | 'self'; + /** Ordered series keys (legend/palette order). */ + keys: string[]; + /** Human labels for keys (only populated for groupBy=user; else key=label). */ + labels: Record; + series: UsageBucket[]; + totals: Record; + byUser?: UsageByUser[]; // admin / local mode only +} + +export async function getUsageDaily(params: { + from?: string; + to?: string; + granularity?: 'hour' | 'day' | 'week' | 'month'; + groupBy?: UsageGroupBy; + tzOffset?: number; +}): Promise { + const qs = new URLSearchParams(); + if (params.from) qs.set('from', params.from); + if (params.to) qs.set('to', params.to); + if (params.granularity) qs.set('granularity', params.granularity); + if (params.groupBy) qs.set('groupBy', params.groupBy); + if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset)); + const q = qs.toString(); + const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`); + if (!res.ok) throw new Error(`Failed to load usage (${res.status})`); + return res.json(); +} diff --git a/ui/src/api/users.ts b/ui/src/api/users.ts new file mode 100644 index 0000000..69d89e7 --- /dev/null +++ b/ui/src/api/users.ts @@ -0,0 +1,28 @@ +// api.ts から分割(挙動不変): ユーザー・組織。 +import { BASE } from './client'; + +export interface UserOrg { + orgId: string; + orgName: string; + fetchedAt: string; +} + +export async function fetchMyOrgs(): Promise { + const res = await fetch('/api/users/me/orgs'); + if (!res.ok) return []; + const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> }; + return orgs; +} + +export interface PickableUser { + id: string; + name: string | null; + email: string | null; + avatarUrl: string | null; +} + +export async function fetchPickableUsers(): Promise { + const res = await fetch(`${BASE}/users/pickable`); + if (!res.ok) return []; + return (await res.json()) as PickableUser[]; +} diff --git a/ui/src/api/workers.ts b/ui/src/api/workers.ts new file mode 100644 index 0000000..5920059 --- /dev/null +++ b/ui/src/api/workers.ts @@ -0,0 +1,118 @@ +// api.ts から分割(挙動不変): ワーカー・バックエンド・ノード状態(Side Info Panel)。 + +export interface WorkerInfo { + id: string; + endpoint: string | null; + model: string | null; + roles: string[]; + enabled: boolean; + /** True if this worker fronts an LLM gateway / proxy (Phase A). */ + proxy?: boolean; + /** Proxy implementation; only 'litellm' is currently shipped. */ + proxyType?: 'litellm'; +} + +export async function fetchWorkers(): Promise { + const res = await fetch('/api/workers', { credentials: 'include' }); + if (!res.ok) return []; + const data = await res.json() as { workers?: WorkerInfo[] }; + return data.workers ?? []; +} + +export interface BackendInfo { + id: string; + model: string | null; + online: boolean; +} + +export interface WorkerBackendsResponse { + source: 'direct' | 'proxy'; + proxyType?: 'litellm'; + backends: BackendInfo[]; + /** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */ + error?: string; +} + +export async function fetchWorkerBackends(workerId: string): Promise { + const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, { + credentials: 'include', + }); + if (!res.ok && res.status !== 502) { + // 502 still carries a typed payload from the server. + return { source: 'direct', backends: [], error: `HTTP ${res.status}` }; + } + return await res.json() as WorkerBackendsResponse; +} + +// ── Side Info Panel ──────────────────────────────────────────────────────── + +export interface NodeStatus { + nodeId: string; + workerId: string; + source: 'direct' | 'proxy'; + online: boolean; + busy: boolean; + busySlots: number; + totalSlots: number; + loadedModel: string | null; + throughputTps: number | null; + lastSeen: string; + lastProbeError?: string; +} + +export interface WorkerStatusBackendRow { + id: string; + state: 'idle' | 'running'; + busySlots: number; + totalSlots: number; + online: boolean | null; +} + +export interface WorkerStatusRow { + id: string; + name: string; + roles: string[]; + state: 'idle' | 'running'; + /** True when this row represents a `proxy: true` worker. */ + proxy: boolean; + /** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */ + busySlots?: number; + totalSlots?: number; + online?: boolean; + /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */ + backends?: WorkerStatusBackendRow[]; + /** + * Occupants of this worker — users whose running jobs use it, each with + * the job kind (`task_kind`: 'agent' = normal, 'reflection' = learning). + * Admin-only — the server only populates this for admins, so non-admin + * clients always receive undefined. + */ + occupants?: Array<{ user: string; kind: string }>; +} + +export async function fetchWorkerStatuses(): Promise { + const res = await fetch('/api/local/dashboard/workers'); + if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`); + return (await res.json()).workers; +} + +/** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */ +export class NodeStatusUnavailableError extends Error { + constructor() { + super('node-status registry not configured'); + this.name = 'NodeStatusUnavailableError'; + } +} + +export async function fetchNodeStatus(): Promise { + const res = await fetch('/api/local/dashboard/node-status'); + if (!res.ok) { + // 503 = registry not configured (e.g. legacy install). Surface as an + // error so the React Query hook can back off polling instead of + // hammering the server every 5s indefinitely. The hook turns this + // into an empty list for rendering. + if (res.status === 503) throw new NodeStatusUnavailableError(); + throw new Error(`Failed to list node status: ${res.status}`); + } + return (await res.json()).nodes; +} diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index f673d4a..5042513 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -301,7 +301,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; // movement-complete arrives (live tool calls during running movement). const toolCall = parseToolCallComment(comment.body); if (toolCall) { - return ; + return ; } // Checklist progress → dedicated card (center, retained as per decision) @@ -435,7 +435,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi {author} · {new Date(createdAt).toLocaleString()}
- +
diff --git a/ui/src/components/chat/ChatPane.tsx b/ui/src/components/chat/ChatPane.tsx index 3f4e8de..f0d4244 100644 --- a/ui/src/components/chat/ChatPane.tsx +++ b/ui/src/components/chat/ChatPane.tsx @@ -9,9 +9,11 @@ import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup' import { SubtaskInlineCard } from './SubtaskInlineCard'; import RotatingTips from './RotatingTips'; import { ToolRequestApproval } from './ToolRequestApproval'; +import { PackageRequestApproval } from './PackageRequestApproval'; import { DelegateLiveConsole } from './DelegateLiveConsole'; import { useJobStream } from '../../hooks/useJobStream'; import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract'; +import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize'; async function toBase64(file: File): Promise { @@ -48,6 +50,14 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ const [sendError, setSendError] = useState(null); const scrollRef = useRef(null); const fileInputRef = useRef(null); + const composerRef = useRef(null); + // Firefox など field-sizing 未対応環境だけ JS で高さ追従(判定は初回のみ)。 + const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []); + useEffect(() => { + if (!needsAutosizeFallback) return; + const el = composerRef.current; + if (el) autosizeTextarea(el, 192); // 192px ≈ 8行 (max-h-48 と一致させる) + }, [draft, needsAutosizeFallback]); // Snapshot of comments.length at submit start. We hold "submitting" until // (a) the new user comment is reflected in the list AND (b) the job is // visibly busy (= picked up by a worker). Without this, the gap between the @@ -272,13 +282,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ lastBackendId={task.latestJob?.lastBackendId ?? null} /> {/* Header */} -
-
-
-

{task.title}

-
#{task.id} · {task.pieceName}
-
-
+
+
+

{task.title}

+ #{task.id} · {task.pieceName} +
{isBusy && (
- {/* Composer */} -
+ {/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に + 添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で + 約114px → 待機時約85px。 */} +
{hasActiveJob && (
)} + {sendError && !isBusy && (
⚠ {sendError} @@ -475,108 +486,111 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
)} - {attachments.length > 0 && ( -
- {attachments.map(a => ( - - {a.name} - - - ))} -
- )} - {/* コンテキスト残量を入力欄の直上に常時表示。概要タブまでスクロールせずに、 - 入力しながら「あとどれくらい書けるか」を把握できる(issue #009)。 */} -
- -
-
- { void handleFiles(e.target.files); e.target.value = ''; }} - /> - +