sync: update from private repo (ddadfd71)
Some checks are pending
CI / build-and-test (push) Waiting to run
@ -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).
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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.
|
||||
|
||||
8
.gitignore
vendored
@ -7,6 +7,14 @@ data/
|
||||
.env.*
|
||||
!.env.example
|
||||
config.yaml
|
||||
# Real config variants / backups at repo root (config-dogfood.yaml,
|
||||
# config.yaml.bak-<timestamp>, ...) 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
|
||||
|
||||
@ -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 \
|
||||
|
||||
16
README.ja.md
@ -14,16 +14,20 @@ OpenAI 互換の LLM エンドポイント([Ollama](https://ollama.com/) / vLL
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
3ペイン構成のワークスペース。左にタスク一覧、中央にライブの会話とレンダリング済み成果物、右に状態・ミッションブリーフ・ファイル・トレースをまとめたサイドパネル:
|
||||
ワークスペース画面。左端にワークスペース一覧、その隣に選択中ワークスペースのチャット一覧、残りの領域にエージェントとのライブ会話とレンダリング済み成果物が並ぶ。概要・進捗・ファイル・トレースはタブで切り替える:
|
||||
|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>ほかのスクリーンショット — タスク一覧・設定</summary>
|
||||
<summary>ほかのスクリーンショット — ワークスペース・チャット一覧・設定</summary>
|
||||
|
||||
実行中 / 待機中 / 完了をひと目で把握できるタスク一覧(クイックフィルタ付き):
|
||||
個人ワークスペースと案件ごとのワークスペースが並ぶ。同じエージェントがどこでも動く:
|
||||
|
||||

|
||||

|
||||
|
||||
実行中 / 待機中 / 完了をひと目で把握できるチャット一覧(クイックフィルタ付き):
|
||||
|
||||

|
||||
|
||||
設定画面。`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)**。
|
||||
|
||||
|
||||
16
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:
|
||||
|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>More screenshots — task list and settings</summary>
|
||||
<summary>More screenshots — workspaces, chat list, and settings</summary>
|
||||
|
||||
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:
|
||||
|
||||

|
||||

|
||||
|
||||
Chats with at-a-glance status (running / waiting / done) and quick filters:
|
||||
|
||||

|
||||
|
||||
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)**.
|
||||
|
||||
|
||||
13
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) を参照。
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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` を編集しない)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 チェック可能
|
||||
|
||||
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 241 KiB |
BIN
docs/screenshots/spaces.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 326 KiB |
@ -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. 認証
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
53
docs/tools/requestpackage.md
Normal file
@ -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**.
|
||||
@ -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 には適用されない |
|
||||
|
||||
@ -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** — インストール済みスキルの一覧を返す。
|
||||
|
||||
## スコープと可視性(重要)
|
||||
|
||||
@ -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 で読むことはできない
|
||||
(ログイン壁で失敗する)。記事は必ずこのツールで取得する
|
||||
|
||||
## 出力フォーマット
|
||||
|
||||
- デフォルト: 構造化テキスト(投稿者・本文・いいね数等)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
# 記事本文トグル付きの直接呼び出しを優先。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)
|
||||
|
||||
|
||||
239
src/bridge/__snapshots__/server-route-order.test.ts.snap
Normal file
@ -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=<anonymous> 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=<anonymous> path=^\\/?(?=\\/|$)",
|
||||
"USE name=router path=^\\/api\\/local\\/spaces\\/?(?=\\/|$)",
|
||||
"USE name=jsonParser path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)",
|
||||
"USE name=router path=^\\/api\\/local\\/dashboard\\/?(?=\\/|$)",
|
||||
"USE name=<anonymous> path=^\\/?(?=\\/|$)",
|
||||
"USE name=<anonymous> 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=<anonymous> 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=<anonymous> 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\\/?(?=\\/|$)",
|
||||
]
|
||||
`;
|
||||
74
src/bridge/a2a-subsystem.ts
Normal file
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string, string> = {}) {
|
||||
return fetch(`${base}/a2a`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function obtainToken(selectedSpaces: string, selectedSkills: string): Promise<string> {
|
||||
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<void>(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);
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<void>;
|
||||
private readonly now: () => string;
|
||||
private readonly maxPollIterations: number;
|
||||
private readonly limiter?: A2aLimiter;
|
||||
|
||||
/** A2A taskId → Maestro jobId のマッピング(cancelTask に使用)。 */
|
||||
private readonly jobIdByA2aTaskId = new Map<string, string>();
|
||||
@ -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<string, unknown>,
|
||||
): 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<void> => {
|
||||
@ -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<void> {
|
||||
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<void> => {
|
||||
|
||||
328
src/bridge/a2a/limiter.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
216
src/bridge/a2a/limiter.ts
Normal file
@ -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<Record<keyof A2aLimits, unknown>>): 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<string, RateBucket>();
|
||||
/** Rolling-hour timestamps for skill budget: grantId → timestamp[] */
|
||||
private readonly _skillHits = new Map<string, number[]>();
|
||||
/** In-memory concurrency reservations: grantId → count */
|
||||
private readonly _reservations = new Map<string, number>();
|
||||
/** Resubscribe slot counters: grantId → count */
|
||||
private readonly _resubscribes = new Map<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<any> | 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<any, void, undefined> = {
|
||||
[Symbol.asyncIterator]() { return this; },
|
||||
next: () => new Promise<never>(() => {}), // blocks indefinitely
|
||||
return: async (v: any) => ({ done: true as const, value: v }),
|
||||
throw: async (e: any): Promise<never> => { 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<any> | 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<any, void, undefined> {
|
||||
await new Promise<void>(() => {}); // 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<any> | 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)', () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<A2aLimits>;
|
||||
}
|
||||
|
||||
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<typeof DefaultRequestHandler>[0],
|
||||
taskStore: ConstructorParameters<typeof DefaultRequestHandler>[1],
|
||||
executor: ConstructorParameters<typeof DefaultRequestHandler>[2],
|
||||
extendedCardProvider?: ConstructorParameters<typeof DefaultRequestHandler>[6],
|
||||
limiter?: A2aLimiter,
|
||||
) {
|
||||
super(agentCard, taskStore, executor, undefined, undefined, undefined, extendedCardProvider);
|
||||
this.limiter = limiter;
|
||||
}
|
||||
|
||||
override async getTask(params: Parameters<DefaultRequestHandler['getTask']>[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<DefaultRequestHandler['cancelTask']>[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,12 +84,99 @@ 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<DefaultRequestHandler['resubscribe']>[0], context?: ServerCallContext) {
|
||||
// 1. Auth — must be first and synchronous
|
||||
this.requireAuthenticated(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<typeof setTimeout> | 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<DefaultRequestHandler['resubscribe']>;
|
||||
}
|
||||
|
||||
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 {
|
||||
const user = context?.user;
|
||||
if (!user || user.isAuthenticated !== true) {
|
||||
@ -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 }));
|
||||
}
|
||||
|
||||
64
src/bridge/admin-gateway-keys-mount.ts
Normal file
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
172
src/bridge/auth-subsystem.ts
Normal file
@ -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;
|
||||
}
|
||||
102
src/bridge/bridge-gateway-mount.ts
Normal file
@ -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;
|
||||
}
|
||||
35
src/bridge/jobs-api.ts
Normal file
@ -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);
|
||||
}
|
||||
@ -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<unknown>;
|
||||
/** 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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
184
src/bridge/local-tasks-package-requests-api.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
163
src/bridge/local-tasks-package-requests-api.ts
Normal file
@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
52
src/bridge/metrics-subsystem.ts
Normal file
@ -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 };
|
||||
}
|
||||
37
src/bridge/novnc-mount.ts
Normal file
@ -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';
|
||||
};
|
||||
}
|
||||
88
src/bridge/server-route-order.test.ts
Normal file
@ -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<string, boolean> };
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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,74 +488,16 @@ 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,
|
||||
// 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,
|
||||
// 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',
|
||||
configManager: opts.configManager,
|
||||
sharedPromRegistry,
|
||||
authActive,
|
||||
listenPort: opts.listenPort,
|
||||
});
|
||||
// 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' });
|
||||
});
|
||||
|
||||
// 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({
|
||||
@ -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,
|
||||
|
||||
81
src/bridge/setup-config-mount.ts
Normal file
@ -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);
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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}` });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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<string, Promise<unknown>>();
|
||||
function withSpaceLock<T>(spaceId: string, fn: () => Promise<T>): Promise<T> {
|
||||
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,
|
||||
const result = await addPackageToSpace({
|
||||
repo,
|
||||
cfg: c,
|
||||
spaceId: space.id,
|
||||
ownerId: space.ownerId,
|
||||
parsed,
|
||||
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 };
|
||||
});
|
||||
|
||||
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}`);
|
||||
|
||||
51
src/bridge/space-python-packages-service.test.ts
Normal file
@ -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<string> {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
121
src/bridge/space-python-packages-service.ts
Normal file
@ -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<string, Promise<unknown>>();
|
||||
export function withSpaceLock<T>(spaceId: string, fn: () => Promise<T>): Promise<T> {
|
||||
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<AddPackageOutcome> {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
688
src/bridge/ssh-admin-api.ts
Normal file
@ -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<SshConnectionRepo['update']>[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;
|
||||
}
|
||||
318
src/bridge/ssh-api-shared.ts
Normal file
@ -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<SshTesterResult>;
|
||||
}
|
||||
|
||||
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=<uid> = 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 (`<algo> <base64>`) 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> — 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<string, unknown> | 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<string, unknown> {
|
||||
// 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<string, unknown> {
|
||||
return { ...g };
|
||||
}
|
||||
|
||||
export function presentAuditRow(a: SshAuditRow): Record<string, unknown> {
|
||||
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();
|
||||
};
|
||||
}
|
||||
589
src/bridge/ssh-user-api.ts
Normal file
@ -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=<id> (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<string, unknown>);
|
||||
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<string>(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<string, unknown>);
|
||||
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<SshConnectionRepo['update']>[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;
|
||||
}
|
||||
36
src/bridge/ui-static.ts
Normal file
@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
26
src/config.a2a-limits.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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 (
|
||||
|
||||
336
src/db/repositories/a2a.ts
Normal file
@ -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<string, unknown>; 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<string, unknown> } | 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<string, unknown> } | 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<string, unknown> }> {
|
||||
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<A2aDelegationRow & { 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
|
||||
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<string, unknown> } | 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<string, unknown> } | 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<string, unknown> }> {
|
||||
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) }));
|
||||
}
|
||||
69
src/db/repositories/app-shares.ts
Normal file
@ -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 };
|
||||
}
|
||||
9
src/db/repositories/audit.ts
Normal file
@ -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<void> {
|
||||
db
|
||||
.prepare('INSERT INTO audit_log (job_id, action, actor, detail) VALUES (?, ?, ?, ?)')
|
||||
.run(jobId, action, actor, JSON.stringify(detail));
|
||||
}
|
||||
430
src/db/repositories/calendar.ts
Normal file
@ -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<string, Record<string, CalendarDayCounts>>;
|
||||
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<CalendarEvent> {
|
||||
// 終了日が開始日と同じ(または無効)なら単日として 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<CalendarEvent[]> {
|
||||
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<CalendarEvent | null> {
|
||||
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<CalendarEvent | null> {
|
||||
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<void> {
|
||||
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<string, CalendarDayCounts>; events: CalendarEvent[] }> {
|
||||
const mod = tzModifier(opts.tzOffsetMin);
|
||||
const days: Record<string, CalendarDayCounts> = {};
|
||||
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<CrossSpaceCalendarMonth> {
|
||||
// 可視スペースの列挙は 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<string, Record<string, CalendarDayCounts>> = {};
|
||||
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 };
|
||||
}
|
||||
487
src/db/repositories/gateway.ts
Normal file
@ -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);
|
||||
}
|
||||
886
src/db/repositories/jobs.ts
Normal file
@ -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<Job> {
|
||||
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<Job | null> {
|
||||
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<SubtaskInfo[]> {
|
||||
return Promise.all(subJobs.map(async (j): Promise<SubtaskInfo> => {
|
||||
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<string, Array<{ user: string; kind: string }>> {
|
||||
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<string, Array<{ user: string; kind: string }>>();
|
||||
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<Omit<Job, 'id' | 'createdAt'>>): Promise<void> {
|
||||
const setClauses: string[] = ["updated_at = datetime('now')"];
|
||||
const params: Record<string, unknown> = { id };
|
||||
|
||||
const fieldMap: Record<string, string> = {
|
||||
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<string, unknown>)[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<boolean> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<Job | null> {
|
||||
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<Job | null> {
|
||||
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<Job | null> {
|
||||
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<Job[]> {
|
||||
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<Job | null> {
|
||||
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<void> {
|
||||
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<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: 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/<parentJobId>').
|
||||
// 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<number> {
|
||||
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<Job[]> {
|
||||
// 同一 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<boolean> {
|
||||
// 同一 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;
|
||||
}
|
||||
254
src/db/repositories/llm-usage.ts
Normal file
@ -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:<team>' 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<string, string> {
|
||||
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<string, string>();
|
||||
for (const r of rows) map.set(r.user_id, r.org_name);
|
||||
return map;
|
||||
}
|
||||
965
src/db/repositories/local-tasks.ts
Normal file
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
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<string, unknown> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
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<import('../../spaces/folder-resolver.js').FolderContext> {
|
||||
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<LocalTask> {
|
||||
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<LocalTask | null> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<MissionBrief>): Promise<MissionBrief | null> {
|
||||
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>): 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<LocalTask | null> {
|
||||
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<LocalTask[]> {
|
||||
// 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<LocalTaskRow & {
|
||||
job_id: string | null;
|
||||
job_repo: string | null;
|
||||
job_issue_number: number | null;
|
||||
job_pr_number: number | null;
|
||||
job_status: string | null;
|
||||
job_piece_name: string | null;
|
||||
job_required_profile: string | null;
|
||||
job_task_class: string | null;
|
||||
job_current_movement: string | null;
|
||||
job_current_activity: string | null;
|
||||
job_instruction: string | null;
|
||||
job_branch_name: string | null;
|
||||
job_worktree_path: string | null;
|
||||
job_attempt: number | null;
|
||||
job_max_attempts: number | null;
|
||||
job_next_retry_at: string | null;
|
||||
job_error_summary: string | null;
|
||||
job_abort_reason: string | null;
|
||||
job_resume_movement: string | null;
|
||||
job_wait_reason: string | null;
|
||||
job_ask_count: number | null;
|
||||
job_worker_id: string | null;
|
||||
job_last_backend_id: string | null;
|
||||
job_parent_job_id: string | null;
|
||||
job_continued_from_job_id: string | null;
|
||||
job_subtask_depth: number | null;
|
||||
job_owner_id: string | null;
|
||||
job_visibility: string | null;
|
||||
job_visibility_scope_org_id: string | null;
|
||||
job_created_at: string | null;
|
||||
job_updated_at: string | null;
|
||||
job_context_prompt_tokens: number | null;
|
||||
job_context_limit_tokens: number | null;
|
||||
job_context_updated_at: string | null;
|
||||
job_browser_session_profile_id: number | null;
|
||||
job_task_kind: string | null;
|
||||
job_payload: string | null;
|
||||
}>;
|
||||
|
||||
// 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<string, Job[]>();
|
||||
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<Omit<LocalTask, 'id' | 'createdAt' | 'latestJob'>>): Promise<void> {
|
||||
const setClauses: string[] = ["updated_at = datetime('now')"];
|
||||
const params: Record<string, unknown> = { taskId };
|
||||
const fieldMap: Record<string, string> = {
|
||||
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<string, unknown>)[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<void> {
|
||||
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<LocalTaskComment> {
|
||||
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<LocalTaskComment[]> {
|
||||
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<LocalTaskComment[]> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
321
src/db/repositories/notifications.ts
Normal file
@ -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<NotifyEventType, boolean>;
|
||||
includeDetails: boolean;
|
||||
v1Migrated: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
export type NotificationPrefsUpdate = Partial<Omit<NotificationPrefs, 'userId' | 'updatedAt'>>;
|
||||
|
||||
|
||||
// ── 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<typeof rowToPushSubscription>[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<string | number> = [];
|
||||
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,
|
||||
};
|
||||
}
|
||||
178
src/db/repositories/provenance.ts
Normal file
@ -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),
|
||||
};
|
||||
}
|
||||
143
src/db/repositories/reflection.ts
Normal file
@ -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<typeof result, 'applied' | 'partial' | 'abstained' | 'rejected' | 'failed'>;
|
||||
if (o in result) (result as Record<string, number>)[o] = r.cnt;
|
||||
result.tokensIn += r.ti ?? 0;
|
||||
result.tokensOut += r.to_ ?? 0;
|
||||
result.pieceEdits += r.pe ?? 0;
|
||||
result.totalRuns += r.cnt;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
305
src/db/repositories/scheduled-tasks.ts
Normal file
@ -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<ScheduledTask> {
|
||||
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<ScheduledTask | null> {
|
||||
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<ScheduledTask[]> {
|
||||
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<ScheduledTask[]> {
|
||||
// 十分遠い未来(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<ScheduledTask | null> {
|
||||
const sets: string[] = [];
|
||||
const values: Record<string, any> = { 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<boolean> {
|
||||
const result = db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
538
src/db/repositories/schema.ts
Normal file
@ -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');
|
||||
}
|
||||
44
src/db/repositories/shared.ts
Normal file
@ -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}`;
|
||||
}
|
||||
495
src/db/repositories/spaces.ts
Normal file
@ -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<Space> {
|
||||
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<Space | null> {
|
||||
// 個人スペースは所有者のみ可視(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<Space[]> {
|
||||
// 個人スペースは所有者のみ可視(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<Space | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Space> {
|
||||
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<void> {
|
||||
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<SpaceMember[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof rowToSpaceInvite>[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<typeof rowToSpaceInvite>[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);
|
||||
}
|
||||
144
src/db/repositories/task-search.ts
Normal file
@ -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<Array<{ taskId: number; taskTitle: string; commentId: number; author: string; kind: string; createdAt: string; text: string }>> {
|
||||
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<string, unknown> = {
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
395
src/db/repositories/tool-requests.ts
Normal file
@ -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<ToolRequestStatus, 'pending'>; 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<PackageRequestStatus, 'pending'>; 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;
|
||||
}
|
||||
563
src/db/repositories/users.ts
Normal file
@ -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<string, unknown> = { 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<string, unknown>;
|
||||
const passport = sess['passport'] as Record<string, unknown> | 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 }));
|
||||
}
|
||||
140
src/db/repositories/worker-nodes.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
const setClauses = [
|
||||
'healthy = @healthy',
|
||||
'last_error = @lastError',
|
||||
"last_seen_at = @now",
|
||||
"updated_at = @now",
|
||||
];
|
||||
const params: Record<string, unknown> = {
|
||||
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<WorkerNode | null> {
|
||||
const row = db
|
||||
.prepare('SELECT * FROM worker_nodes WHERE worker_id = ?')
|
||||
.get(workerId) as WorkerNodeRow | undefined;
|
||||
return row ? rowToWorkerNode(row) : null;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
98
src/db/repository.package-requests.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
29
src/db/repository.transcript-only-io.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
5770
src/db/repository.ts
@ -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);
|
||||
|
||||
-- ワークスペースファイルの来歴(プロヴェナンス)台帳。永続ワークスペースが
|
||||
-- 複数タスク間で共有されるため、各ファイルを「どのタスクが作った / アップロード
|
||||
-- したか」「最後に変更したのは誰か」を追跡する。ファイル本文にはタグを埋めない。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -172,18 +172,45 @@ export async function handleLLMError(
|
||||
): Promise<MovementResult | null> {
|
||||
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) {
|
||||
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,
|
||||
`${errorMessage}\n\nRecovery via dedup, compaction, and summarization could not bring the prompt under the safe limit.`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<PieceRunResult> {
|
||||
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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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: "<pkg>", 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<string, unknown>, 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)
|
||||
|
||||