From a6d1879d63a5ab8c6abbbba0a259f19e2d4e1575 Mon Sep 17 00:00:00 2001 From: oss-sync Date: Mon, 29 Jun 2026 01:32:25 +0000 Subject: [PATCH] sync: update from private repo (c764df2f) --- config.yaml.example | 1 + docs/movement-control-flow-tools.md | 71 +++ docs/tools/delegate.md | 14 +- docs/tools/spawnsubtask.md | 64 ++- docs/tools/xsearch.md | 31 +- pieces/brainstorming.yaml | 81 ++- pieces/general.yaml | 74 ++- pieces/piece-builder.yaml | 2 +- pieces/research-sub.yaml | 110 ---- pieces/research.yaml | 65 +-- scripts/install-x-adapter.sh | 81 +++ scripts/x-adapter/requirements.txt | 9 + scripts/x-adapter/test_x_adapter.py | 187 ++++++ scripts/x-adapter/x_adapter.py | 543 ++++++++++++++++++ src/bridge/dashboard-api.test.ts | 18 +- src/bridge/dashboard-api.ts | 12 +- src/bridge/dashboard-workers.test.ts | 39 ++ src/bridge/dashboard-workers.ts | 29 +- src/bridge/space-api.transfer.test.ts | 293 ++++++++++ src/bridge/space-api.ts | 92 +++ src/config.test.ts | 27 + src/config.ts | 5 + src/db/repository.test.ts | 68 +++ src/db/repository.ts | 33 ++ src/engine/agent-loop.ts | 42 ++ src/engine/piece-runner.test.ts | 86 +++ src/engine/piece-runner.ts | 75 ++- src/engine/tools/core.ts | 11 + src/engine/tools/docs.ts | 2 + src/engine/tools/orchestration.test.ts | 36 ++ src/engine/tools/orchestration.ts | 37 +- src/engine/tools/tool-categories.test.ts | 8 + src/engine/tools/tool-categories.ts | 11 +- src/engine/tools/web.test.ts | 19 + src/engine/tools/web.ts | 18 +- src/engine/workspace-ssh-connections.test.ts | 275 +++++++++ src/engine/workspace-tool-policy.test.ts | 10 + src/metrics/tool-name-allowlist.ts | 2 +- .../space-credential-transfer.test.ts | 233 ++++++++ src/services/space-credential-transfer.ts | 222 +++++++ src/worker.inherit-job-context.test.ts | 96 ++++ src/worker.ts | 163 +++++- ui/src/api.ts | 7 + .../dashboard/WorkerStatusWidget.test.tsx | 55 ++ .../dashboard/WorkerStatusWidget.tsx | Bin 8497 -> 9729 bytes .../detail/tabs/OverviewTab.test.tsx | 45 ++ ui/src/components/detail/tabs/OverviewTab.tsx | 40 +- .../spaces/ImportFromWorkspaceDialog.tsx | 349 +++++++++++ ui/src/components/spaces/SpaceDetail.tsx | 34 +- ui/src/components/spaces/SpaceRail.tsx | 2 +- .../components/spaces/SpaceToolSettings.tsx | 1 + ui/src/components/userfolder/McpPanel.tsx | 38 +- .../userfolder/SshConnectionsPanel.tsx | 44 +- ui/src/content/help/00-changelog.md | 52 ++ ui/src/content/help/01-intro.md | 8 + ui/src/content/help/02-tasks.md | 4 + ui/src/content/help/03-running.md | 6 + ui/src/content/help/04-results.md | 11 + ui/src/content/help/05-pieces.md | 4 +- ui/src/content/help/10-subtasks.md | 43 +- ui/src/content/help/13-mcp.md | 9 + ui/src/content/help/14-ssh.md | 25 +- ui/src/content/help/15-llm-gateway.md | 1 + ui/src/content/help/16-tools.md | 6 +- ui/src/content/help/17-settings.md | 10 + ui/src/content/help/19-admin.md | 20 +- ui/src/content/help/21-workspaces.md | 2 + ui/src/i18n/locales/en/dashboard.json | 3 +- ui/src/i18n/locales/en/detail.json | 2 +- ui/src/i18n/locales/en/spaces.json | 3 +- ui/src/i18n/locales/ja/dashboard.json | 3 +- ui/src/i18n/locales/ja/detail.json | 2 +- ui/src/i18n/locales/ja/spaces.json | 3 +- ui/src/lib/spaceTasks.test.ts | 45 +- ui/src/lib/spaceTasks.ts | 36 +- 75 files changed, 3874 insertions(+), 334 deletions(-) create mode 100644 docs/movement-control-flow-tools.md delete mode 100644 pieces/research-sub.yaml create mode 100755 scripts/install-x-adapter.sh create mode 100644 scripts/x-adapter/requirements.txt create mode 100644 scripts/x-adapter/test_x_adapter.py create mode 100644 scripts/x-adapter/x_adapter.py create mode 100644 src/bridge/space-api.transfer.test.ts create mode 100644 src/engine/workspace-ssh-connections.test.ts create mode 100644 src/services/space-credential-transfer.test.ts create mode 100644 src/services/space-credential-transfer.ts create mode 100644 src/worker.inherit-job-context.test.ts create mode 100644 ui/src/components/dashboard/WorkerStatusWidget.test.tsx create mode 100644 ui/src/components/spaces/ImportFromWorkspaceDialog.tsx diff --git a/config.yaml.example b/config.yaml.example index 78e864b..6d696c8 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -196,6 +196,7 @@ ask: subtasks: max_depth: 2 # SpawnSubTask のネスト最大深度 max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数 + spawn_stagger_ms: 1000 # SpawnSubTask の連続発火を間引く遅延(ms)。一斉着地で GPU 優先順位を飛び越えるのを防ぐ。0 で無効 # ─── Context (LLM コンテキスト管理) ─────────────────────────── # context: diff --git a/docs/movement-control-flow-tools.md b/docs/movement-control-flow-tools.md new file mode 100644 index 0000000..83e5e98 --- /dev/null +++ b/docs/movement-control-flow-tools.md @@ -0,0 +1,71 @@ +# Movement 制御フローツールと park シグナル + +エンジン内部リファレンス。「ツールを呼ぶと movement が**待機/終了状態に遷移**するが、その遷移先は**ピース毎の `rules` に書かなくてよい**」という仕組みのまとめ。BrowseWeb のログイン待ち・RequestTool の承認待ち・(提案中の)WaitSubTask が同じ骨格を共有する。今後この型を流用する時の下敷き。 + +## 要点 + +movement の遷移には **2 系統**ある。 + +| 系統 | 遷移先の決まり方 | LLM への提示 | 例 | +|------|------------------|--------------|-----| +| **rules ベース** | ピースの `rules[].next` を `buildTransitionTool` が enum 化 | `transition` ツールの選択肢として提示 | 別 movement への中間遷移 | +| **sentinel `next`(park/terminal)** | ツールまたは engine が `MovementResult.next` に**特別な値**を直接セット | 専用ツール(`complete` 等)として提示。`rules` 不要 | 終了・各種待機 | + +後者が今回の主題。**ツールが「作業をする」のではなく「movement を畳む制御シグナルを出す」**。`complete` がまさにこれで、`BrowseWeb`(ログイン待ち)・`RequestTool`(承認待ち)も同じ。`rules` に依存しないので、`rules: []` の単一 movement ピース(`chat` 等)でも一律に効く。 + +## 仕組みの流れ + +``` +agent-loop.ts + LLM が制御フローツールを呼ぶ + └─ finishMovement({ next: '', resumeMovement?, waitReason? }) を返す + │ MovementResult.next に sentinel を載せる + ▼ +piece-runner.ts mapMovementResult(result, ...) + └─ next の値で分岐し PieceRunResult を返す + ├─ 終了系 → kind:'done' / status: completed|aborted|cancelled + └─ 待機系 → kind:'done' / status: waiting_* + resumeMovement(+waitReason) + ▼ +worker.ts + └─ waiting_* のジョブを停車。条件成立で resumeMovement から再開 + (waiting_subtasks=子全完了 / waiting_human=ユーザー回答・ログイン・承認) +``` + +ポイントは、**待機系も「ジョブを停車してワーカーを解放」する**こと。ツール内で同期ブロックして待つのではない(後述の鉄則)。 + +## sentinel カタログ(`mapMovementResult` の分岐・origin/main 時点) + +| `next` | 生成元 | 結果 status | resumeMovement | waitReason | 種別 | +|--------|--------|-------------|----------------|-----------|------| +| `COMPLETE` | `complete` ツール | `completed` | — | — | 終了 | +| `ABORT`(`cancelled` 含む) | キャンセル経路 | `cancelled` | — | — | 終了 | +| `ABORT` | `complete`/各種ガード(`text_only_limit`・`max_iterations` 等) | `aborted` | — | — | 終了 | +| `ASK` | `complete({status:'needs_user_input'})` | `waiting_human`(上限超で `aborted`) | 現在の movement | — | 待機 | +| `WAIT_SUBTASKS` | `transition`(rules 経由)/**WaitSubTask ツール(提案)** | `waiting_subtasks` | `default_next`(ツール版は現在の movement に変更予定) | — | 待機 | +| `WAITING_HUMAN_BROWSER` | `BrowseWeb`(ログイン要求時) | `waiting_human` | 現在の movement | `browser_login` | 待機 | +| `WAITING_HUMAN_TOOL_REQUEST` | `RequestTool` | `waiting_human` | 現在の movement | `tool_request` | 待機 | +| `null` | 異常(ツール未決着) | `error` | — | — | 異常 | + +> `WAIT_SUBTASKS` だけは現状 `rules[].next` にも書ける(rules ベース経由)。ツール版(WaitSubTask)を足すと、**同じ sentinel に「ルール不要の入口」**が増える。下流の `mapMovementResult` 分岐は共有。 + +## 新しい park ツールを足すレシピ + +1. **ツール定義**を該当モジュール(例 `orchestration.ts`)の `TOOL_DEFS` に追加。 +2. **agent-loop で制御フローツールとして識別**し、`finishMovement({ next: '<新SENTINEL>', resumeMovement, waitReason })` を返す(`WAITING_HUMAN_BROWSER` の実装が雛形)。 +3. **`mapMovementResult` に分岐を追加**(piece-runner.ts)。終了なら `status: 'aborted'|'completed'`、待機なら `status: 'waiting_*'` + `resumeMovement`(+`waitReason`)。 +4. **worker の再開条件**を実装/再利用。既存の `waiting_human` / `waiting_subtasks` のどちらかに寄せられるなら新 status は不要。 +5. **型**(`MovementResult.next` のユニオン、`waitReason`)を更新。 + +既存の待機 status(`waiting_human` / `waiting_subtasks`)に相乗りできるなら、3〜4 は分岐追加だけで済むことが多い。 + +## 鉄則・gotcha + +- **ツール内で同期ブロックして待たない。** 待機は必ず「停車=ワーカー解放」で表現する。in-tool で `await` し続けると親ワーカーを占有し、最悪ワーカー枯渇でデッドロックする(`WAIT_SUBTASKS` が遷移=停車である理由)。 +- **resume 先を意識する。** `default_next` が `COMPLETE` のピース(単一 movement)で再開させると終了に飛ぶ。多くの待機ツールは**現在の movement に再入**させて、再開後に結果を読んで `complete` させる。 +- **terminal と park を混同しない。** `COMPLETE`/`ABORT` はジョブを終わらせる(再開なし)。`WAITING_*`/`WAIT_SUBTASKS` は停車→再開。`resumeMovement` の有無が分かれ目。 +- **sentinel は `rules` の enum に出さない。** これらは engine-internal。`rules[].next` に書けるのは別 movement 名と `WAIT_SUBTASKS` のみ(`loadPiece`/lint が `COMPLETE`/`ABORT`/`ASK` を reject する)。 + +## 関連 + +- 設計: `docs/design/2026-06-26-subtask-wait-and-safety.html`(WaitSubTask ツール=この型の新規適用例) +- 実装: `src/engine/agent-loop.ts`(`finishMovement` / 制御フローツール識別)、`src/engine/piece-runner.ts`(`mapMovementResult`)、`src/worker.ts`(待機ジョブの停車・再開) diff --git a/docs/tools/delegate.md b/docs/tools/delegate.md index debddf2..43adcec 100644 --- a/docs/tools/delegate.md +++ b/docs/tools/delegate.md @@ -2,6 +2,8 @@ サブエージェントを同期的にインライン実行して、重い処理を委譲し本体のコンテキストを節約する。サブエージェントの中間ターンは親に見えないため、複雑な作業の最終結果だけを取得できる。 +**タスク分解の既定手段**。research / general / brainstorming など調査・分析系のピースは、重いサブ調査を Delegate で **1 件ずつ直列に** こなす設計になっている。並列の別ジョブ(SpawnSubTask)はデフォルト無効で、本当に並列が必要なワークスペースだけが **設定 → ツール** タブで明示的に有効化する。ほとんどのタスクは Delegate で分解が完結するため、まず Delegate を検討する。 + ## 基本 ```js @@ -27,12 +29,18 @@ Delegate({ - 「特定キーワードで 30 件検索し, 記事要約→集約」→ 重い WebFetch も delegate 内で完結 - 「複数 PDF を OCR → テキスト抽出 → 解析」→ 前処理を delegate, 後続は親が実行 -### SpawnSubTask が向いているケース +### SpawnSubTask が向いているケース(opt-in) -- **複数の独立したテーマ**(A, B, C に分解)→ 並列実行が必須 +> SpawnSubTask はデフォルト無効。使うにはワークスペースの **設定 → ツール** タブで有効化する(Bash などと同じセンシティブ扱いの opt-in ツール)。有効化していないワークスペースでは Delegate での直列分解だけになる。 + +並列の別ジョブが本当に効く、次のようなケースに限って有効化を検討する: + +- **複数の独立したテーマ**(A, B, C に分解)で、壁時計時間の短縮が重要 - **サブタスク間に依存がない**(A が完了してから B という制約がない) - **各サブの成果物がそれぞれ意味を持つ**(集約不要または集約が簡単) +GPU・実行枠を多く消費するため、逐次で足りる場合は Delegate を優先する。 + ### Delegate が向かないケース - **対話が必要**(ASK ツールを呼ぶ)→ 子の ASK は親に bubbles up する @@ -77,7 +85,7 @@ Bash({ command: "cat logs/activity.log | tail -20" }) ## 制限 -- **直列実行**: Delegate × N 個を呼ぶとシリアルに実行される(並列不可)。高速化は SpawnSubTask で並列化 +- **直列実行**: Delegate × N 個を呼ぶとシリアルに実行される(並列不可)。並列が必要なら SpawnSubTask(要 opt-in 有効化)を使う。直列のぶん壁時計時間はやや長くなるが、GPU 負荷は軽く、各サブが綺麗なコンテキストで動くので品質は揃いやすい - **ネスト深さ**: 約 2 レベル(delegate 内からさらに delegate を呼ぶのは許可だが, 3 段階目以上は危険) - **完了待ちブロック**: 親は子が完了するまでブロックされるため、1 回の delegate は焦点を絞ること - **ASK 必須情報**: サブエージェントが ASK を呼んだら, 親に `[delegate 要追加情報] ...` と bubble up される。親が回答を `transition({lessons: "..." })` で与える diff --git a/docs/tools/spawnsubtask.md b/docs/tools/spawnsubtask.md index 998e0f2..1751441 100644 --- a/docs/tools/spawnsubtask.md +++ b/docs/tools/spawnsubtask.md @@ -2,6 +2,10 @@ タスクを並列サブタスクに分解して実行する。各サブタスクは独立した worker(ジョブ)で動き、完了後に親タスクが結果を集約する。 +> **デフォルト無効(opt-in)**。SpawnSubTask は Bash などと同じセンシティブ扱いのツールで、初期状態ではエージェントに提示されない。使うにはワークスペースの **設定 → ツール** タブで明示的に有効化する。有効化していない場合、SpawnSubTask は呼べず、分解は [`Delegate`](./delegate.md)(直列・インプロセス)で行う。 +> +> タスク分解の既定手段は Delegate(直列)。research / general / brainstorming などのピースは Delegate でサブ調査を 1 件ずつこなす。SpawnSubTask は「壁時計時間の短縮のために独立テーマを本当に並列で走らせたい」ワークスペースだけが有効化する。 + ## 基本 ```js @@ -16,11 +20,14 @@ SpawnSubTask({ ## いつ使うか +まず Delegate(直列)で足りるかを検討する。SpawnSubTask は opt-in で有効化したうえで、**並列の別ジョブで壁時計時間を縮めたい**場合に限って使う。 + ### 並列分解が効果的なケース - 2 つ以上の **独立したテーマ**(互いに参照しない) - 各テーマが軽くなく、調査・処理に時間がかかる - 分解後の各タスクが単独でも意味を持つ成果物になる +- 壁時計時間の短縮が GPU・実行枠の追加消費に見合う 例: - 「3 つの製品比較レポート」→ 製品ごとに 3 サブタスク @@ -47,11 +54,66 @@ SpawnSubTask({ - 省略時: 親と同じ classifier ロジックで自動選択 - 明示する場合: `research`, `general`, `office-process` 等の piece 名を指定 +## WaitSubTask(完了待ち) + +`SpawnSubTask` でサブタスクを起動したあとは、**`WaitSubTask`(引数なし)を 1 回呼んで全サブタスクの完了を待つ**。 + +```js +SpawnSubTask({ title: "A の調査", instruction: "..." }) +SpawnSubTask({ title: "B の調査", instruction: "..." }) +SpawnSubTask({ title: "C の調査", instruction: "..." }) +WaitSubTask() // ここで全部の完了を待つ +// 再開後、subtasks/*/result.md を Read して集約 → complete +``` + +- `WaitSubTask` は **同期的にブロックしない**。呼ぶとジョブはいったん停止(park)して worker を解放し、全サブタスクが終わると **同じ movement のまま再開** する +- 再開時、各サブタスクの結果は `subtasks/{index}/result.md` に書き出されている。Read で読んで集約レポートを作る +- 起動した分の成果物は `subtasks/{index}/output/` 以下にも残る + +### ワークフロー + +1. `SpawnSubTask` を必要な数だけ呼ぶ(並列に起動される) +2. `WaitSubTask()` を 1 回呼ぶ +3. 再開後、`subtasks/*/result.md` を読んで集約する +4. `complete` で終了する + +### auto-park(取りこぼし防止) + +サブタスクがまだ走っている最中に `complete`(success)を呼んでも、エンジンが**偽の完了を返さず、自動で park して完了を待つ**。`WaitSubTask` を呼び忘れても結果を取りこぼさない安全網。とはいえ意図を明示するため、原則 `WaitSubTask` を明示的に呼ぶこと。 + +### orphan の自動キャンセル + +親ジョブが**失敗・キャンセル・リトライ**したとき、まだ走っているサブタスクは**自動でキャンセル**される。親が消えたのに子だけ GPU を使い続ける「ゾンビサブタスク」は発生しない。 + +ただし **ASK(ユーザー回答待ち)で停止した場合は子を残す**。親があとで再開して結果を使うため。 + +### delegate との違い + +| | delegate | SpawnSubTask + WaitSubTask | +|---|---|---| +| 既定 | **デフォルト有効**(分解の既定手段) | **デフォルト無効**(要 opt-in 有効化) | +| 実行 | 直列・インプロセス | 並列・別ジョブ | +| 待機 | 自動(1 ツール呼び出しで完結) | `WaitSubTask` で待つ | +| 負荷 | GPU・実行枠が軽い | 並列ぶん多く消費する | +| 向く用途 | 逐次の分解・重い委譲全般 | 独立テーマを並列で短時間化したいとき | + +## 起動の間隔(spawn_stagger_ms) + +短時間に大量の `SpawnSubTask` を呼ぶと、その一団が GPU スロットの優先順位を一気に奪ってしまう。これを避けるため、`SpawnSubTask` のキュー投入の間に既定で 1 秒の間隔を入れる。 + +```yaml +subtasks: + spawn_stagger_ms: 1000 # SpawnSubTask 投入間の間隔(ミリ秒、デフォルト 1000、0 で無効) +``` + +- `0` にすると間隔を入れず即時に投入する +- エージェントから見た呼び出し方は変わらない(透過的に間隔が入るだけ) + ## 結果の参照 サブタスク完了後、親タスクは: +- `subtasks/{index}/result.md` にサブタスクの要約結果がある(Read で読む) - `subtasks/{index}/output/` 以下にサブタスクの成果物がある -- Read で参照して集約レポートを作成する ## 制限 diff --git a/docs/tools/xsearch.md b/docs/tools/xsearch.md index 371c40e..689f075 100644 --- a/docs/tools/xsearch.md +++ b/docs/tools/xsearch.md @@ -4,7 +4,8 @@ twitter-cli を内部で呼び出して X (旧 Twitter) のデータを取得す ## 認証設定(必須) -twitter-cli を動かすには Cookie 認証が必要。Settings UI の "Tools" セクションで設定: +X ツールは `tools.x_cli_command` で指定した外部 CLI を呼び出す。どちらの CLI でも +Cookie 認証が必要。Settings UI の "Tools" セクションで設定: - **X Auth Token**: ブラウザの `auth_token` cookie の値 - **X ct0**: ブラウザの `ct0` cookie の値 @@ -14,6 +15,34 @@ twitter-cli を動かすには Cookie 認証が必要。Settings UI の "Tools" 設定が無いと「認証エラー」で失敗する。 +## バックエンド CLI(x-adapter / twitter-cli) + +2026 年に X.com が Web クライアントを新バンドル(x-web / `sign.o*.js`)へ移行し、 +従来の twitter-cli が依存する `x_client_transaction` の transaction-id 生成が壊れた +(`Failed to init ClientTransaction` → 全リクエストが HTTP 404 → `exited with code 1`)。 + +復旧のため、新バンドルに追従している **twscrape**(transaction-id 生成 `XClIdGen` + +検索/ユーザー投稿/詳細)と **twikit**(ホームタイムラインの GraphQL endpoint 定義)を +使う twitter-cli 互換アダプタ `x-adapter` を導入した。x.ts は無改修で、`x_cli_command` +を切り替えるだけ。 + +セットアップ: +```bash +./scripts/install-x-adapter.sh # 隔離 venv に twscrape/twikit を入れ ~/.local/bin/x-adapter を作る +# config.yaml: +# tools: +# x_cli_command: [x-adapter] +``` + +X が再び取得を壊した場合(おおむね 2〜4 週ごと)の修復レバー: +```bash +./scripts/install-x-adapter.sh --upgrade # twscrape / twikit を最新へ +``` + +`x-adapter` のサブコマンドは twitter-cli と同じ(`search` / `user-posts` / `tweet` / +`feed`)で、同じ YAML を stdout に吐く。実装は `scripts/x-adapter/x_adapter.py`、 +pure マッピング関数の単体テストは `scripts/x-adapter/test_x_adapter.py`。 + ## XSearch — 投稿検索 ```js diff --git a/pieces/brainstorming.yaml b/pieces/brainstorming.yaml index dfeb527..253effd 100644 --- a/pieces/brainstorming.yaml +++ b/pieces/brainstorming.yaml @@ -4,7 +4,7 @@ description: | 選ぶべき場合: 「どうすべきか」「どの方針が良いか」を多角的に検討する必要がある 選ぶべきでない場合: 答えが調査で明確になるタスク、具体的な成果物の作成が主目的 max_movements: 999 -initial_movement: decompose +initial_movement: delegate_research triggers: keywords: @@ -15,59 +15,42 @@ triggers: - アイデア出し movements: - - name: decompose - edit: false + - name: delegate_research + edit: true persona: facilitator instruction: | - 課題を分析し、複数の視点から検討すべきポイントを特定してください。 + 課題を複数の視点に分解し、各視点を delegate で1件ずつ直列に検討させ、 + 最後に推奨方針へ統合する。重い検討は各サブに任せ、自分の文脈は軽く保つ。 - 1. タスクの指示を注意深く読み、何が求められているかを理解する - 2. 検討すべき視点や切り口を 2〜5 個に分解する + 手順: + 1. タスクの指示を注意深く読み、検討すべき視点や切り口を 2〜5 個に分解する。 - 例: 技術的実現性、コスト/リソース、ユーザーへの影響、リスク、長期的な拡張性 - 3. 各視点ごとに SpawnSubTask で独立した調査・検討タスクを作成する - - piece は "research-sub" を指定する - - instruction には「この視点で分析し、output/analysis.md に結論と根拠を書いてください」と具体的に記述 - - 各サブタスクは異なる分析レンズを持つよう明確に指定する - 4. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する - allowed_tools: - - Read - - Grep - - Glob - - SpawnSubTask - rules: - - condition: "全てのサブタスクを SpawnSubTask で登録し終えた" - next: WAIT_SUBTASKS - - condition: 全てのサブタスクを登録し終えた(SpawnSubTask不可の場合は自分で分析を完了した) - next: aggregate - default_next: aggregate - - - name: aggregate - edit: true - persona: analyst - instruction: | - 各サブタスクの検討結果を統合し、推奨方針をまとめてください。 - - 1. subtasks/ ディレクトリを確認する(Glob: subtasks/*/output/**) - 2. 各サブタスクの分析結果を読み込む - 3. 共通点・相違点・トレードオフを整理する - 4. 総合的な推奨方針を output/recommendation.md に作成する + 各視点に上から 1 始まりの連番を振る(1, 2, 3 ...)。 + 2. 各視点について delegate を1回ずつ呼ぶ(1件ずつ順番に・直列)。 + delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、 + このタスク全体の背景・目的を要約して渡す): + - 検討する視点(分析レンズ)と、課題の目的・背景 + - 「この視点で分析し、結論と根拠を出す。WebSearch / WebFetch / 一次情報で + 裏を取り、モデルの内部知識だけで書かない」指示 + - 「結論・根拠・メリット/デメリット・リスクの構成で + output/research/perspective-{連番}.md に Write せよ」指示 + - 「完了したら 3〜5 行の要約だけを返せ。本文は返さずファイルに書け」 + - 「あなたは末端の担当。delegate / SpawnSubTask は呼ぶな」 + 各視点が異なる分析レンズを持つよう明確に指定する。 + 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。 + 3. Glob で output/research/*.md の件数を確認する。 + 4. 各サブの要約とファイルを基に推奨方針を output/recommendation.md に Write する: - 各視点からの主要な発見 - - トレードオフの整理 + - 共通点・相違点・トレードオフの整理 - 推奨アプローチとその根拠 - リスクと緩和策 - 5. 完了したら verify に遷移する - - allowed_tools: - - Read - - Glob - - Grep - - Write - - Edit - - 'mcp__*' + - 各視点の詳細へ相対リンク [perspective-{連番}](./research/perspective-{連番}.md) で繋ぐ + 5. output/recommendation.md を書き終えたら verify へ遷移する + allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*'] + default_next: verify rules: - condition: "output/recommendation.md に推奨方針をまとめた" next: verify - default_next: verify - name: verify edit: false @@ -77,9 +60,9 @@ movements: 確認手順: 1. まず Glob で output/ 内のファイル一覧を確認する - 2. output/recommendation.md がなければ「修正が必要」と判断し aggregate に差し戻す + 2. output/recommendation.md がなければ「修正が必要」と判断し delegate_research に差し戻す 3. ファイルがあれば Read で内容を確認し、各視点の分析が含まれているか・推奨方針が論理的かをチェックする - 4. 不足や誤りがあれば、`transition({next_step: "aggregate", summary: ...})` で差し戻す。summary は次の形式で書く: + 4. 不足や誤りがあれば、`transition({next_step: "delegate_research", summary: ...})` で差し戻す。summary は次の形式で書く: [判定] needs_fix ## 問題点 - [ファイル名:行番号または項目名] 何が問題か @@ -88,7 +71,7 @@ movements: ## 合格基準 - 再レビューで何を確認するか ## 次にやること - - aggregate で最初に着手すべき具体的な修正 + - delegate_research で最初に着手すべき具体的な修正 5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める ## チェックシート確認 @@ -105,7 +88,7 @@ movements: ## 終了方法のまとめ - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - - 修正必要: `transition({next_step: "aggregate", summary: "差し戻し指摘"})` (上記形式で) + - 修正必要: `transition({next_step: "delegate_research", summary: "差し戻し指摘"})` (上記形式で) - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` allowed_tools: [Read, Glob, Grep] # default_next is the engine-internal fallback (context overflow / ASK @@ -113,4 +96,4 @@ movements: default_next: COMPLETE rules: - condition: output/ にファイルがない、または内容に不足がある - next: aggregate + next: delegate_research diff --git a/pieces/general.yaml b/pieces/general.yaml index 7f8fb6b..276fb0d 100644 --- a/pieces/general.yaml +++ b/pieces/general.yaml @@ -8,48 +8,36 @@ max_movements: 999 initial_movement: execute movements: - - name: decompose - edit: false + - name: delegate_research + edit: true persona: orchestrator instruction: | - 入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。 + 入力把握で立てた分割調査計画に従い、各テーマを delegate で1件ずつ直列に + 深掘りさせ、最後に統合レポートにまとめる。重い調査は各サブに任せ、 + 自分の文脈は軽く保つ。 手順: - 1. 入力把握で立てた計画を思い出す(ファイル読み込みは不要) - 2. 各テーマに対して SpawnSubTask を呼び出す(2〜5 個程度) - - title: テーマを簡潔に(例:「A社の製品ラインアップ調査」) - - instruction: 何を調べて output/result.md にどう書くかを具体的に記述 - - piece: 調査系は "research-sub"、汎用作業は "general"(サブタスクからさらに分解しないこと) - 3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する - - ## instruction の書き方例 - 「〇〇について調査し、output/result.md に以下を含めてまとめてください: - - 概要と主要な特徴 - - メリット・デメリット - - 具体的な数値・事例(可能な限り)」 - allowed_tools: [SpawnSubTask] - default_next: aggregate - rules: - - condition: 全サブタスクを SpawnSubTask で登録し終えた - next: WAIT_SUBTASKS - - condition: 全てのサブタスクを登録し終えた(SpawnSubTask不可の場合は自分で分析を完了した) - next: aggregate - - - name: aggregate - edit: true - persona: analyst - instruction: | - 各サブタスクの結果が subtasks/ ディレクトリに格納されています。 - - 手順: - 1. Glob で subtasks/*/result.md を確認する - 2. 各 result.md を Read で読み込む - 3. subtasks/*/output/ も確認して追加の成果物があれば Read する - 4. 全結果を統合して output/report.md に最終レポートを作成する - - 各サブタスクの主要な知見を統合(矛盾・重複は整理) + 1. 入力把握で立てた調査テーマを思い出す(2〜5 個程度)。各テーマに上から + 1 始まりの連番を振る(1, 2, 3 ...)。 + 2. 各テーマについて delegate を1回ずつ呼ぶ(1件ずつ順番に・直列)。 + delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、 + このタスク全体の背景・目的を要約して渡す): + - 調べる/作業するテーマと目的・背景 + - 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示 + - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で + output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは + output/images/ に保存し Markdown で埋め込め」指示 + - 「完了したら 3〜5 行の要約だけを返せ。本文は返さずファイルに書け」 + - 「あなたは末端の担当。delegate / SpawnSubTask は呼ぶな」 + 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。 + 3. Glob で output/research/*.md の件数を確認する。 + 4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する: + - 各テーマの主要な知見を統合(矛盾・重複は整理) + - 比較・対照が必要なら表形式で整理 - 全体のまとめと結論を付ける + - 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ 5. output/report.md を書き終えたら verify へ遷移する - allowed_tools: [Read, Glob, Grep, Write, Edit, 'mcp__*'] + allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*'] default_next: verify rules: - condition: output/report.md に統合レポートを作成した @@ -68,14 +56,16 @@ movements: 3. 不明点があれば WebSearch/WebFetch で調べる 4. 「今日のニュース」「最新動向」など時刻依存の依頼は、必ず最初に WebSearch を実行する - ## 並列分解の判断 + ## 分割調査の判断(delegate_research) - 以下の場合は decompose を積極的に検討する: + 以下の場合は delegate_research を積極的に検討する: - 複数の独立した調査対象がある(例: 3社の比較調査、複数トピックのリサーチ) - 各調査が互いに依存せず、結果を最後に統合すればよい - 全体を 1 回の execute で処理すると context が溢れるリスクがある + (delegate_research では各テーマを delegate に1件ずつ任せ、本文はファイルに書かせて + 自分の文脈を軽く保つ。直列実行) - decompose を使わない場合: + delegate_research を使わない場合: - 単一テーマの作業(ファイル編集、1 つの調査など) - 各ステップが前のステップの結果に依存する逐次的な作業 @@ -118,14 +108,14 @@ movements: ## 終了 / 遷移方法 - **次の verify へ**: `transition({next_step: "verify"})` - - **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})` + - **分割調査が効率的 → delegate_research へ**: `transition({next_step: "delegate_research"})` - **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, AddCalendarEvent, ListCalendarEvents, 'mcp__*'] default_next: verify rules: - - condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した - next: decompose + - condition: 2つ以上の独立したテーマがあり、分割して delegate に任せるのが効率的と判断した + next: delegate_research - condition: output/ にファイルを書き出した next: verify diff --git a/pieces/piece-builder.yaml b/pieces/piece-builder.yaml index f310615..6893f6b 100644 --- a/pieces/piece-builder.yaml +++ b/pieces/piece-builder.yaml @@ -30,7 +30,7 @@ movements: - 各ステップで使うツール(`allowed_tools`) - 入力と出力の形式 - ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "SpawnSubTask" })`)。 + ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "delegate" })`)。 ### YAML 構造の制約 ```yaml diff --git a/pieces/research-sub.yaml b/pieces/research-sub.yaml deleted file mode 100644 index 4044a7b..0000000 --- a/pieces/research-sub.yaml +++ /dev/null @@ -1,110 +0,0 @@ -name: research-sub -description: | - サブタスク専用の調査ピース。親タスクの decompose から SpawnSubTask で起動される。 - dig → analyze → verify の 3 ステップで調査を完結させる。 - さらなるサブタスク分解(SpawnSubTask)は行わない。 -max_movements: 999 -initial_movement: dig - -movements: - - name: dig - edit: true - persona: researcher - instruction: | - ## 最初のステップ: 入力把握と調査計画 - - 情報収集に着手する前に、調査対象と目的を整理する: - 1. Glob でワークスペース全体のファイル一覧を確認する(input/ だけでなくルート直下も含む) - 2. 指示で言及されているファイルがあれば適切なツールで内容を把握する(カタログ参照、詳細は ReadToolDoc) - 3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする - 4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする - - ## 計画に従って情報を収集する - - WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。 - テキストで回答するだけでは不十分。 - - ## 検索の原則(必須) - - - モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る - - output/ に既存ファイルがある場合でもその内容を鵜呑みにせず、検索で正確性を確認する - - ## 一次情報へのアクセスと捏造禁止(厳守) - - - YouTube 動画の内容を調査する場合は、必ず GetYouTubeTranscript で字幕を取得してから作業する - - 一次情報に直接アクセスできなかった場合: - - Web 検索の断片的な情報から内容を推測・捏造してはならない - - アクセスできなかった旨を明記し、取得できた範囲の情報のみで成果物を作成する - - ## 画像・ビジュアル素材の収集(必須) - - 調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。 - - ## 終了 / 遷移方法 - - **次の analyze へ**: `transition({next_step: "analyze"})` - - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] - default_next: analyze - rules: - - condition: output/ に情報を書き出した - next: analyze - - condition: 追加調査が必要 - next: dig - - - name: analyze - edit: true - persona: analyst - instruction: | - 収集した情報を分析し、調査レポートを output/ に作成する。 - 重要なポイント、トレンド、結論をまとめる。 - 必ず Write ツールで output/ にレポートファイルを書き出すこと。 - 前のステップから指摘事項がある場合は、それに対応すること。 - - ## 検索の原則(必須) - - - レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと - - 情報が不足している場合は、ここでも追加の WebSearch/WebFetch を行い裏付けを取る - - 「これまでのレビュー指摘」がある場合は、各項目を漏れなく解消すること - - ## 画像の活用(必須) - - output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む: - `![説明](./images/ファイル名.png)` - allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] - default_next: verify - rules: - - condition: output/ にレポートを書き出した - next: verify - - condition: 追加調査が必要 - next: dig - - - name: verify - edit: false - persona: reviewer - instruction: | - output/ のレポートを確認する。 - - 確認手順: - 1. まず Glob で output/ 内のファイル一覧を確認する - 2. output/ にファイルが1つもなければ「不足がある」と判断し analyze に差し戻す - 3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする - 4. 不足があれば analyze に差し戻す - - ## 合格時 - - 合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。 - result はそのままユーザー(親タスク)に返される。 - - 調査結果・発見・結論を簡潔にまとめる - - 表・リスト・見出しなど Markdown 書式を活用して読みやすくする - - ## 終了方法 - - 合格: `complete({status: "success", result: "調査結果のまとめ"})` - - 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` - - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache] - default_next: COMPLETE - rules: - - condition: output/ にファイルがない、または内容に不足がある - next: analyze diff --git a/pieces/research.yaml b/pieces/research.yaml index d4157ab..1021610 100644 --- a/pieces/research.yaml +++ b/pieces/research.yaml @@ -10,42 +10,35 @@ max_movements: 999 initial_movement: dig movements: - - name: decompose - edit: false + - name: delegate_research + edit: true persona: orchestrator instruction: | - 入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。 + dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、 + 最後に統合レポートにまとめる。重い調査は各サブに任せ、自分の文脈は軽く保つ。 手順: - 1. 入力把握で立てた調査テーマを思い出す(ファイル読み込みは不要) - 2. 各テーマに対して SpawnSubTask を呼び出す(2〜5 個程度、piece は "research-sub") - 3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する - - instruction には「何を調べて output/result.md にどう書くか」を具体的に記述する - (概要・主要な特徴・数値や事例・まとめと考察 など、構成を明示)。 - allowed_tools: [SpawnSubTask] - default_next: aggregate - rules: - - condition: 全サブタスクを SpawnSubTask で登録し終えた - next: WAIT_SUBTASKS - - condition: 全てのサブタスクを登録し終えた(SpawnSubTask不可の場合は自分で調査を完了した) - next: aggregate - - - name: aggregate - edit: true - persona: analyst - instruction: | - 各サブタスクの調査結果が subtasks/ ディレクトリに格納されている。 - - 手順: - 1. Glob で subtasks/*/result.md と subtasks/*/output/ を確認する - 2. 各 result.md と追加成果物を Read で読み込む - 3. 全結果を統合して output/report.md に最終レポートを作成する + 1. dig で立てた調査テーマを思い出す(2〜5 個程度)。各テーマに上から 1 始まりの + 連番を振る(1, 2, 3 ...)。 + 2. 各テーマについて delegate を1回ずつ呼ぶ(1件ずつ順番に・直列)。 + delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、 + このタスク全体の背景・目的を要約して渡す): + - 調べるテーマと目的・背景 + - 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示 + - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で + output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは + output/images/ に保存し Markdown で埋め込め」指示 + - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」 + - 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」 + 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。 + 3. Glob で output/research/*.md の件数を確認する。 + 4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する: - 各テーマの主要な知見を統合(矛盾・重複は整理) - 比較・対照が必要なら表形式で整理 - 全体のまとめと考察を付ける - 4. output/report.md を書き終えたら verify へ遷移する - allowed_tools: [Read, Glob, Grep, Write, Edit, 'mcp__*'] + - 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ + 5. output/report.md を書き終えたら verify へ遷移する + allowed_tools: [delegate, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, GetYouTubeTranscript, SearchYouTube, SearchAmazon, SearchPlaces, GetDirections, ReverseGeocode, XSearch, XUserPosts, XPostDetail, SearchMicrosoftLearn, FetchMicrosoftLearn, 'mcp__*'] default_next: verify rules: - condition: output/report.md に統合レポートを作成した @@ -63,14 +56,16 @@ movements: 3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする 4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする - ## 並列分解の判断 + ## 分割調査の判断(delegate_research) - decompose を積極的に検討するケース: + delegate_research を積極的に検討するケース: - 複数の独立した調査対象がある(例: 3社の比較、複数技術の比較) - 各調査が互いに依存せず、結果を最後に統合すればよい - 全体を 1 回の dig → analyze で処理すると context が溢れるリスクがある + (delegate_research では各テーマを delegate に1件ずつ任せ、本文はファイルに書かせて + 自分の文脈を軽く保つ。直列実行) - decompose を使わないケース: + delegate_research を使わないケース: - 単一テーマの調査 - 各ステップが前のステップの結果に依存する逐次的な調査 @@ -109,15 +104,15 @@ movements: ## 終了 / 遷移方法 - **次の analyze へ**: `transition({next_step: "analyze"})` - - **並列分解 → decompose へ**: `transition({next_step: "decompose"})` + - **分割調査 → delegate_research へ**: `transition({next_step: "delegate_research"})` - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] default_next: analyze rules: - - condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した - next: decompose + - condition: 2つ以上の独立した調査テーマがあり、分割して delegate に任せるのが効率的と判断した + next: delegate_research - condition: output/ に情報を書き出した next: analyze - condition: 追加調査が必要 diff --git a/scripts/install-x-adapter.sh b/scripts/install-x-adapter.sh new file mode 100755 index 0000000..81f0863 --- /dev/null +++ b/scripts/install-x-adapter.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# x-adapter インストーラ。 +# +# 何をするか: +# X.com の x-web バンドル移行で twitter-cli の transaction-id 生成が壊れたため、 +# twscrape (新バンドル対応の XClIdGen) + twikit を使う twitter-cli 互換アダプタを +# 隔離 venv に入れ、`x-adapter` という実行ファイルを ~/.local/bin に置く。 +# +# 使い方: +# ./scripts/install-x-adapter.sh # 新規インストール +# ./scripts/install-x-adapter.sh --upgrade # twscrape/twikit を最新へ (X が再破壊した時の修復レバー) +# +# 終わったら config.yaml に: +# tools: +# x_cli_command: [x-adapter] +set -euo pipefail + +MODE="install" +for arg in "$@"; do + case "$arg" in + --upgrade) MODE="upgrade" ;; + *) echo "Usage: ./scripts/install-x-adapter.sh [--upgrade]" >&2; exit 1 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$SCRIPT_DIR/x-adapter/x_adapter.py" +REQ="$SCRIPT_DIR/x-adapter/requirements.txt" + +PREFIX="${X_ADAPTER_PREFIX:-$HOME/.local/share/x-adapter}" +BIN_DIR="${X_ADAPTER_BIN:-$HOME/.local/bin}" +VENV="$PREFIX/venv" + +if [ ! -f "$SRC" ]; then + echo "ERROR: $SRC not found." >&2 + exit 1 +fi + +mkdir -p "$PREFIX" "$BIN_DIR" + +if [ ! -d "$VENV" ]; then + echo "Creating venv at $VENV ..." + python3 -m venv "$VENV" +fi + +echo "Installing/upgrading dependencies ..." +"$VENV/bin/pip" install -q --upgrade pip >/dev/null +if [ "$MODE" = "upgrade" ]; then + "$VENV/bin/pip" install -q --upgrade twscrape twikit httpx PyYAML +else + "$VENV/bin/pip" install -q -r "$REQ" +fi + +echo "Installing x_adapter.py ..." +cp "$SRC" "$PREFIX/x_adapter.py" + +WRAPPER="$BIN_DIR/x-adapter" +cat > "$WRAPPER" </dev/null || echo 'version unknown')" + +echo "" +echo "Smoke test (x-adapter --version) ..." +if "$WRAPPER" --version >/dev/null 2>&1; then + echo "Smoke test passed." +else + echo "WARN: x-adapter --version failed." >&2 +fi + +echo "" +echo "Next:" +echo " 1. Ensure cookies are set in config.yaml (tools.x_auth_token / tools.x_ct0)" +echo " 2. Set tools.x_cli_command: [x-adapter] (or [$WRAPPER] if ~/.local/bin is not on PATH)" +echo " 3. Restart the server. XSearch / XUserPosts / XPostDetail / XTimeline now route through x-adapter." +echo "" +echo "If X breaks scraping again (every 2-4 weeks): ./scripts/install-x-adapter.sh --upgrade" diff --git a/scripts/x-adapter/requirements.txt b/scripts/x-adapter/requirements.txt new file mode 100644 index 0000000..2983ce0 --- /dev/null +++ b/scripts/x-adapter/requirements.txt @@ -0,0 +1,9 @@ +# x-adapter runtime deps. +# twscrape: X の新 x-web バンドル向け x-client-transaction-id 生成 (XClIdGen) + 検索/ユーザー投稿/詳細 +# twikit: ホームタイムラインの GraphQL endpoint / FEATURES 定義の供給元 +# httpx: home timeline の raw GraphQL POST +# PyYAML: twitter-cli 互換 YAML の出力 +twscrape>=0.19.0 +twikit>=2.3.3 +httpx>=0.27 +PyYAML>=6.0 diff --git a/scripts/x-adapter/test_x_adapter.py b/scripts/x-adapter/test_x_adapter.py new file mode 100644 index 0000000..af97222 --- /dev/null +++ b/scripts/x-adapter/test_x_adapter.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""x_adapter の pure マッピング関数の単体テスト。 + +ネットワーク・twscrape/twikit/httpx 無しで回る (それらは x_adapter 内で遅延 import)。 +実行: python3 scripts/x-adapter/test_x_adapter.py +CI/手動どちらでも。失敗で exit 1。 +""" +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import x_adapter as xa # noqa: E402 + +_fails = [] + + +def check(name, cond): + print(("PASS" if cond else "FAIL"), name) + if not cond: + _fails.append(name) + + +def ns(**kw): + return types.SimpleNamespace(**kw) + + +# ── parse_tweet_ref ── +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: junk -> None", xa.parse_tweet_ref("not a tweet") is None) +check("ref: empty -> None", xa.parse_tweet_ref("") is None) + +# ── twitter_time_iso ── +iso = xa.twitter_time_iso("Wed Oct 10 20:19:24 +0000 2018") +check("time: iso year", iso.startswith("2018-10-10T20:19:24")) +check("time: empty", xa.twitter_time_iso("") == "") +check("time: garbage passthrough", xa.twitter_time_iso("xx") == "xx") + +# ── twscrape_tweet_to_dict ── +photo = ns(url="https://pbs.twimg.com/media/AAA.jpg") +video = ns(thumbnailUrl="https://pbs.twimg.com/poster.jpg", + variants=[ns(url="https://video.twimg.com/x.mp4", bitrate=832000, contentType="video/mp4")]) +media = ns(photos=[photo], videos=[video], animated=[]) +user = ns(id_str="12", displayname="Jack", username="jack", + profileImageUrl="https://pbs.twimg.com/pp.jpg") +tw = ns(id_str="20", id=20, rawContent="hello", user=user, + likeCount=5, retweetCount=2, replyCount=1, viewCount=99, + date=ns(isoformat=lambda: "2018-10-10T20:19:24+00:00"), + retweetedTweet=None, media=media) +d = xa.twscrape_tweet_to_dict(tw) +check("tw: id", d["id"] == "20") +check("tw: text", d["text"] == "hello") +check("tw: author.screenName", d["author"]["screenName"] == "jack") +check("tw: author.name", d["author"]["name"] == "Jack") +check("tw: metrics.views", d["metrics"]["views"] == 99) +check("tw: createdAtISO", d["createdAtISO"] == "2018-10-10T20:19:24+00:00") +check("tw: isRetweet False", d["isRetweet"] is False) +check("tw: photo media", d["media"][0] == {"type": "photo", "url": "https://pbs.twimg.com/media/AAA.jpg"}) +vid = d["media"][1] +check("tw: video type", vid["type"] == "video") +check("tw: video poster url", vid["url"] == "https://pbs.twimg.com/poster.jpg") +check("tw: video variant url", vid["variants"][0]["url"] == "https://video.twimg.com/x.mp4") +check("tw: video variant bitrate", vid["variants"][0]["bitrate"] == 832000) +check("tw: video variant contentType", vid["variants"][0]["contentType"] == "video/mp4") + +tw_rt = ns(id_str="21", rawContent="rt", user=user, retweetedTweet=ns(id="1"), media=None, + likeCount=0, retweetCount=0, replyCount=0, viewCount=0, date=None) +check("tw: isRetweet True", xa.twscrape_tweet_to_dict(tw_rt)["isRetweet"] is True) +check("tw: no media -> empty", xa.twscrape_tweet_to_dict(tw_rt)["media"] == []) + +# ── parse_graphql_tweet_result: legacy-user shape ── +result_legacy = { + "rest_id": "100", + "core": {"user_results": {"result": { + "rest_id": "12", + "legacy": {"name": "Jack", "screen_name": "jack", + "profile_image_url_https": "https://pbs.twimg.com/pp.jpg"}, + }}}, + "views": {"count": "1234"}, + "legacy": { + "full_text": "from timeline", + "created_at": "Wed Oct 10 20:19:24 +0000 2018", + "favorite_count": 7, "retweet_count": 3, "reply_count": 2, + "extended_entities": {"media": [ + {"type": "photo", "media_url_https": "https://pbs.twimg.com/media/P.jpg"}, + {"type": "video", "media_url_https": "https://pbs.twimg.com/poster.jpg", + "video_info": {"variants": [ + {"url": "https://video.twimg.com/v.mp4", "bitrate": 256000, "content_type": "video/mp4"}, + {"url": "https://video.twimg.com/v.m3u8", "content_type": "application/x-mpegURL"}, + ]}}, + ]}, + }, +} +g = xa.parse_graphql_tweet_result(result_legacy) +check("gql: id", g["id"] == "100") +check("gql: text", g["text"] == "from timeline") +check("gql: screenName(legacy)", g["author"]["screenName"] == "jack") +check("gql: name(legacy)", g["author"]["name"] == "Jack") +check("gql: views parsed", g["metrics"]["views"] == 1234) +check("gql: likes", g["metrics"]["likes"] == 7) +check("gql: createdAtISO", g["createdAtISO"].startswith("2018-10-10T20:19:24")) +check("gql: photo media", g["media"][0]["type"] == "photo") +check("gql: video variant only-with-url", len(g["media"][1]["variants"]) == 2) +check("gql: video contentType mapped", g["media"][1]["variants"][0]["contentType"] == "video/mp4") + +# ── parse_graphql_tweet_result: core-user shape (X migration) ── +result_core = { + "rest_id": "101", + "core": {"user_results": {"result": { + "rest_id": "12", + "core": {"name": "Jill", "screen_name": "jill"}, + "avatar": {"image_url": "https://pbs.twimg.com/jill.jpg"}, + }}}, + "views": {"count": "5"}, + "legacy": {"full_text": "core shape", "created_at": "", "favorite_count": 1, + "retweet_count": 0, "reply_count": 0}, +} +gc = xa.parse_graphql_tweet_result(result_core) +check("gql-core: screenName", gc["author"]["screenName"] == "jill") +check("gql-core: name", gc["author"]["name"] == "Jill") +check("gql-core: avatar", gc["author"]["profileImageUrl"] == "https://pbs.twimg.com/jill.jpg") + +# ── parse_graphql_tweet_result: visibility wrapper + no-legacy ── +wrapped = {"__typename": "TweetWithVisibilityResults", "tweet": result_legacy} +check("gql: visibility wrapper unwrapped", xa.parse_graphql_tweet_result(wrapped)["id"] == "100") +check("gql: no legacy -> None", xa.parse_graphql_tweet_result({"rest_id": "9"}) is None) +check("gql: non-dict -> None", xa.parse_graphql_tweet_result(None) is None) + +# ── parse_home_timeline ── +payload = {"data": {"home": {"home_timeline_urt": {"instructions": [ + {"type": "TimelineClearCache"}, + {"type": "TimelineAddEntries", "entries": [ + {"entryId": "tweet-100", "content": {"itemContent": {"tweet_results": {"result": result_legacy}}}}, + {"entryId": "cursor-top-x", "content": {}}, + {"entryId": "tweet-101", "content": {"itemContent": {"tweet_results": {"result": result_core}}}}, + ]}, +]}}}} +rows = xa.parse_home_timeline(payload, limit=50) +check("home: 2 tweets extracted", len(rows) == 2) +check("home: order preserved", rows[0]["id"] == "100" and rows[1]["id"] == "101") +check("home: limit respected", len(xa.parse_home_timeline(payload, limit=1)) == 1) +check("home: empty payload", xa.parse_home_timeline({}, limit=10) == []) + +# module-nested tweets (TimelineModule items[] + TimelineAddToModule moduleItems[]) +payload_mod = {"data": {"home": {"home_timeline_urt": {"instructions": [ + {"type": "TimelineAddEntries", "entries": [ + {"entryId": "home-conversation-1", "content": {"items": [ + {"item": {"itemContent": {"tweet_results": {"result": result_legacy}}}}, + ]}}, + ]}, + {"type": "TimelineAddToModule", "moduleItems": [ + {"item": {"itemContent": {"tweet_results": {"result": result_core}}}}, + ]}, +]}}}} +mrows = xa.parse_home_timeline(payload_mod, limit=50) +check("home: module items extracted", len(mrows) == 2 and mrows[0]["id"] == "100" and mrows[1]["id"] == "101") +check("home: who-to-follow user skipped", xa.parse_home_timeline( + {"data": {"home": {"home_timeline_urt": {"instructions": [ + {"type": "TimelineAddEntries", "entries": [ + {"entryId": "cursor-top", "content": {"cursorType": "Top"}}, + ]}]}}}}, limit=10) == []) + +# ── emit (optional, needs PyYAML) ── +try: + import io + import yaml # noqa: F401 + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + xa.emit([g]) + finally: + sys.stdout = old + parsed = yaml.safe_load(buf.getvalue()) + check("emit: ok flag", parsed["ok"] is True) + check("emit: data roundtrip", parsed["data"][0]["id"] == "100") + check("emit: unicode preserved", "from timeline" in buf.getvalue()) +except ImportError: + print("SKIP emit tests (PyYAML not installed)") + +print() +if _fails: + print(f"{len(_fails)} FAILED: {_fails}") + sys.exit(1) +print("ALL PASSED") diff --git a/scripts/x-adapter/x_adapter.py b/scripts/x-adapter/x_adapter.py new file mode 100644 index 0000000..3ffbff1 --- /dev/null +++ b/scripts/x-adapter/x_adapter.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +"""twitter-cli 互換アダプタ (twscrape + twikit バックエンド) + +なぜ存在するか: + X.com が 2026 年に Web クライアントを新バンドル (x-web / `sign.o*.js`) へ移行し、 + twitter-cli が依存する x_client_transaction (1.0.2) の `ondemand.s` 解析が壊れた。 + transaction-id を生成できず全 X ツールが HTTP 404 で全滅 (`exited with code 1`)。 + twscrape の `XClIdGen` は新バンドルに追従済みなので、txid 生成と GraphQL 取得を + twscrape に任せ、twitter-cli が出していた YAML と同じ形を stdout に吐く。 + 既存の src/engine/tools/x.ts は無改修で、config の tools.x_cli_command を + このアダプタに向けるだけで動く。 + +サブコマンド (twitter-cli と同じ呼ばれ方): + search -t --max N --yaml [--full-text] [--compact] + user-posts --max N --yaml [...] + tweet --yaml [...] + feed --max N --yaml [-t following] [...] # ホームタイムライン + --version + +認証 (env 経由、x.ts が設定): + TWITTER_AUTH_TOKEN, TWITTER_CT0 + +出力 YAML (x.ts の parseXPostsFromYaml / downloadTweetMedia が解釈する形): + ok: true + schema_version: '1' + data: + - id, text, author{name,screenName,profileImageUrl,id}, + metrics{likes,retweets,replies,views}, createdAtISO, isRetweet, + media[]{type, url, variants[]{url,bitrate,contentType}} + +注意: twscrape / twikit / httpx は遅延 import (関数内)。pure なマッピング関数だけ +なら依存なしで import でき、test_x_adapter.py がネットワーク・依存なしで回る。 +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from email.utils import parsedate_to_datetime + +VERSION = "x-adapter 1.0.0 (twscrape backend)" + +# twitter-cli の YAML スキーマバージョン (x.ts は使わないが互換のため踏襲) +SCHEMA_VERSION = "1" + + +# ── pure helpers (依存なし・テスト対象) ────────────────────────────── + +def parse_tweet_ref(raw: str) -> str | None: + """tweet ID もしくは status URL から数値 ID を取り出す。""" + 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) + return m.group(1) if m else None + + +def twitter_time_iso(created_at: str) -> str: + """GraphQL legacy の 'Wed Oct 10 20:19:24 +0000 2018' を ISO8601 へ。""" + if not created_at: + return "" + try: + return parsedate_to_datetime(created_at).isoformat() + except Exception: + return created_at + + +def _iso_from_dt(dt) -> str: + if dt is None: + return "" + try: + return dt.isoformat() + except Exception: + return str(dt) + + +def _as_dict(obj) -> dict: + if isinstance(obj, dict): + return obj + return getattr(obj, "__dict__", {}) or {} + + +def _dig(d, *keys, default=None): + """ネストした dict を安全にたどる。""" + cur = d + for k in keys: + if not isinstance(cur, dict): + return default + cur = cur.get(k) + if cur is None: + return default + return cur + + +# ── twscrape Tweet オブジェクト → YAML dict ─────────────────────────── + +def _media_from_twscrape(t) -> list: + out: list = [] + media = getattr(t, "media", None) + if not media: + return out + for p in getattr(media, "photos", None) or []: + url = getattr(p, "url", None) if not isinstance(p, dict) else p.get("url") + if url: + out.append({"type": "photo", "url": url}) + for v in getattr(media, "videos", None) or []: + vd = _as_dict(v) + variants = [] + for var in (getattr(v, "variants", None) or vd.get("variants") or []): + d = _as_dict(var) + vu = d.get("url") + if vu: + variants.append({ + "url": vu, + "bitrate": d.get("bitrate") or 0, + "contentType": d.get("contentType") or d.get("content_type") or "", + }) + out.append({ + "type": "video", + "url": (getattr(v, "thumbnailUrl", None) or vd.get("thumbnailUrl") or ""), + "variants": variants, + }) + for g in getattr(media, "animated", None) or []: + gd = _as_dict(g) + variants = [] + for var in (getattr(g, "variants", None) or gd.get("variants") or []): + d = _as_dict(var) + if d.get("url"): + variants.append({"url": d["url"], "bitrate": d.get("bitrate") or 0, + "contentType": d.get("contentType") or d.get("content_type") or ""}) + out.append({ + "type": "animated_gif", + "url": (getattr(g, "thumbnailUrl", None) or gd.get("thumbnailUrl") or ""), + "variants": variants, + }) + return out + + +def twscrape_tweet_to_dict(t) -> dict: + u = getattr(t, "user", None) + return { + "id": str(getattr(t, "id_str", None) or getattr(t, "id", "") or ""), + "text": getattr(t, "rawContent", "") or "", + "author": { + "id": str(getattr(u, "id_str", "") or "") if u else "", + "name": (getattr(u, "displayname", "") or "") if u else "", + "screenName": (getattr(u, "username", "") or "") if u else "", + "profileImageUrl": (getattr(u, "profileImageUrl", "") or "") if u else "", + }, + "metrics": { + "likes": getattr(t, "likeCount", 0) or 0, + "retweets": getattr(t, "retweetCount", 0) or 0, + "replies": getattr(t, "replyCount", 0) or 0, + "views": getattr(t, "viewCount", 0) or 0, + }, + "createdAtISO": _iso_from_dt(getattr(t, "date", None)), + "isRetweet": getattr(t, "retweetedTweet", None) is not None, + "media": _media_from_twscrape(t), + } + + +# ── raw GraphQL (home timeline) result → YAML dict ──────────────────── + +def parse_graphql_tweet_result(result: dict) -> dict | None: + """GraphQL の tweet_results.result を YAML dict に変換。 + + X は user フィールドを legacy → core へ移行中なので両方から拾う。 + """ + if not isinstance(result, dict): + return None + if result.get("__typename") == "TweetWithVisibilityResults": + result = result.get("tweet", result) + legacy = result.get("legacy") or {} + if not legacy: + return None + + user_result = _dig(result, "core", "user_results", "result", default={}) or {} + user_legacy = user_result.get("legacy") or {} + user_core = user_result.get("core") or {} + views = _dig(result, "views", "count", default=0) + + media = [] + for me in (_dig(legacy, "extended_entities", "media", default=[]) or []): + mt = me.get("type") + if mt == "photo": + media.append({"type": "photo", "url": me.get("media_url_https")}) + elif mt in ("video", "animated_gif"): + variants = [ + {"url": v.get("url"), "bitrate": v.get("bitrate", 0), + "contentType": v.get("content_type", "")} + for v in (_dig(me, "video_info", "variants", default=[]) or []) + if v.get("url") + ] + media.append({"type": mt, "url": me.get("media_url_https"), "variants": variants}) + + return { + "id": str(result.get("rest_id") or legacy.get("id_str") or ""), + "text": legacy.get("full_text", "") or "", + "author": { + "id": str(user_result.get("rest_id", "") or ""), + "name": user_legacy.get("name") or user_core.get("name") or "", + "screenName": (user_legacy.get("screen_name") + or user_core.get("screen_name") or ""), + "profileImageUrl": (user_legacy.get("profile_image_url_https") + or _dig(user_result, "avatar", "image_url", default="") or ""), + }, + "metrics": { + "likes": legacy.get("favorite_count", 0) or 0, + "retweets": legacy.get("retweet_count", 0) or 0, + "replies": legacy.get("reply_count", 0) or 0, + "views": int(views) if str(views).isdigit() else 0, + }, + "createdAtISO": twitter_time_iso(legacy.get("created_at", "")), + "isRetweet": "retweeted_status_result" in legacy, + "media": media, + } + + +def _collect_tweet_results(node, out: list, limit: int) -> None: + """entry / module item から tweet_results.result を拾って out に積む。 + + 通常ツイート (content.itemContent...) と、モジュール内アイテム + (content.items[].item.itemContent... / moduleItems[]) の両方をたどる。 + ツイート以外 (who-to-follow ユーザー, cursor) は parse_graphql_tweet_result が + None を返すので自然に除外される。 + """ + if len(out) >= limit: + return + content = node.get("content") or node.get("item") or {} + res = _dig(content, "itemContent", "tweet_results", "result") + if res: + row = parse_graphql_tweet_result(res) + if row: + out.append(row) + return + for it in (content.get("items") or []): + _collect_tweet_results(it, out, limit) + if len(out) >= limit: + return + + +def parse_home_timeline(payload: dict, limit: int) -> list: + """HomeTimeline / HomeLatestTimeline の GraphQL レスポンスを dict 配列へ。 + + TimelineAddEntries の entries[] と TimelineAddToModule の moduleItems[] の + 両方を走査する。 + """ + out: list = [] + instructions = (_dig(payload, "data", "home", "home_timeline_urt", "instructions", default=[]) or []) + for ins in instructions: + itype = ins.get("type") + if itype == "TimelineAddEntries": + for entry in ins.get("entries", []) or []: + _collect_tweet_results(entry, out, limit) + if len(out) >= limit: + return out + elif itype == "TimelineAddToModule": + for it in ins.get("moduleItems", []) or []: + _collect_tweet_results(it, out, limit) + if len(out) >= limit: + return out + return out + + +# ── 出力 ────────────────────────────────────────────────────────────── + +def emit(rows: list) -> None: + import yaml # PyYAML + doc = {"ok": True, "schema_version": SCHEMA_VERSION, "data": rows} + sys.stdout.write(yaml.safe_dump(doc, allow_unicode=True, sort_keys=False)) + + +def fail(message: str, code: int = 1) -> None: + """twitter-cli と同じく非ゼロ終了 + stderr。x.ts がエラー扱いにする。""" + sys.stderr.write(message.rstrip() + "\n") + sys.exit(code) + + +def _silence_logs() -> None: + """twscrape は loguru で出力する。万一 stdout に乗ると x.ts の YAML.parse が + 壊れるので、全 loguru sink を落とす (現状は stderr 行きで実害なしだが防御的に)。""" + try: + from loguru import logger as _loguru + _loguru.remove() + except Exception: + pass + + +# ── backend (twscrape / twikit、遅延 import) ────────────────────────── + +def _creds() -> tuple[str, str]: + import os + auth = os.environ.get("TWITTER_AUTH_TOKEN", "").strip() + ct0 = os.environ.get("TWITTER_CT0", "").strip() + if not auth or not ct0: + fail("x-adapter: TWITTER_AUTH_TOKEN / TWITTER_CT0 are not set. " + "Configure tools.x_auth_token / tools.x_ct0 in config.yaml.") + return auth, ct0 + + +_TMP_PREFIX = "xadapter-" + + +def _cleanup_stale_tmp() -> None: + """過去の SIGKILL タイムアウト等で残った temp dir (cookie 入り sqlite) を掃除。 + + x.ts はタイムアウト時に SIGKILL するため finally が走らず temp dir が残りうる。 + 起動時に 1 時間以上前の `xadapter-*` を削除し、平文 cookie の /tmp 残留を抑える。 + """ + import glob + import os + import shutil + import tempfile + import time + try: + now = time.time() + for d in glob.glob(os.path.join(tempfile.gettempdir(), _TMP_PREFIX + "*")): + try: + if os.path.isdir(d) and now - os.path.getmtime(d) > 3600: + shutil.rmtree(d, ignore_errors=True) + except Exception: + pass + except Exception: + pass + + +async def _ensure_alive(api) -> None: + """直近のリクエストでアカウントが 401/banned 扱いになっていないか確認。 + + twscrape は 401/429 を例外にせずログして停止し、空イテレーションを返す。空結果が + 『本当に0件』か『認証切れ/レート制限』かを区別するため、停止時に無効化される + account.active を見て、無効なら nonzero で fail する (x.ts がエラー表示)。 + """ + try: + accs = await api.pool.get_all() + except Exception: + return + if accs and not getattr(accs[0], "active", True): + fail("x-adapter: X rejected the session (cookies expired/banned or rate-limited). " + "Refresh tools.x_auth_token / tools.x_ct0, or wait out the rate limit.") + + +async def _make_api(): + """cookie だけで twscrape API を用意 (login_all は呼ばない)。""" + import os + import shutil + import tempfile + from twscrape import API + _silence_logs() # twscrape import 後に呼ぶ (import 時に loguru sink が張られるため) + auth, ct0 = _creds() + tmpdir = tempfile.mkdtemp(prefix=_TMP_PREFIX) + db = os.path.join(tmpdir, "accounts.db") + api = API(db) + await api.pool.add_account( + "x_adapter", "-", "x_adapter@local", "-", + cookies=f"auth_token={auth}; ct0={ct0}", + ) + return api, tmpdir, shutil + + +def _search_product(tab: str) -> str: + t = (tab or "Latest").strip().lower() + return { + "latest": "Latest", "top": "Top", + "photos": "Media", "videos": "Media", "media": "Media", + }.get(t, "Latest") + + +async def cmd_search(args) -> None: + api, tmpdir, shutil = await _make_api() + try: + rows = [] + kv = {"product": _search_product(args.tab)} + async for t in api.search(args.query, limit=args.max, kv=kv): + rows.append(twscrape_tweet_to_dict(t)) + if len(rows) >= args.max: + break + if not rows: + await _ensure_alive(api) # 空が認証切れ由来なら nonzero fail + emit(rows) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +async def cmd_user_posts(args) -> None: + api, tmpdir, shutil = await _make_api() + try: + login = args.username.lstrip("@").strip() + user = await api.user_by_login(login) + if not user: + await _ensure_alive(api) # 認証切れなら not found より先に auth エラー + fail(f"x-adapter: user '{login}' not found") + rows = [] + async for t in api.user_tweets(user.id, limit=args.max): + rows.append(twscrape_tweet_to_dict(t)) + if len(rows) >= args.max: + break + if not rows: + await _ensure_alive(api) + emit(rows) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +async def cmd_tweet(args) -> None: + tid = parse_tweet_ref(args.tweet) + if not tid: + fail(f"x-adapter: could not parse tweet id from '{args.tweet}'") + api, tmpdir, shutil = await _make_api() + try: + t = await api.tweet_details(int(tid)) + if not t: + await _ensure_alive(api) # 認証切れなら nonzero + fail(f"x-adapter: tweet {tid} not found or inaccessible") + emit([twscrape_tweet_to_dict(t)]) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +async def cmd_feed(args) -> None: + """ホームタイムライン。twscrape の txid + twikit の endpoint で raw GraphQL。""" + import httpx + from urllib.parse import urlparse + import twscrape.xclid as xc + from twikit.client.gql import Endpoint + from twikit.constants import FEATURES + _silence_logs() # twscrape import 後に呼ぶ + + auth, ct0 = _creds() + following = (args.tab or "").strip().lower() == "following" + url = Endpoint.HOME_LATEST_TIMELINE if following else Endpoint.HOME_TIMELINE + path = urlparse(url).path + query_id = path.rstrip("/").split("/")[-2] + # X web app が使う公開 web bearer (guest/web 共通の定数) + bearer = ("AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs" + "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA") + + gen = await xc.XClIdGen.create() + tid = gen.calc("POST", path) + variables = { + "count": args.max, + "includePromotedContent": False, + "latestControlAvailable": True, + "requestContext": "launch", + "seenTweetIds": [], + } + body = {"variables": variables, "features": FEATURES, "queryId": query_id} + headers = { + "authorization": "Bearer " + bearer, + "x-csrf-token": ct0, + "x-twitter-auth-type": "OAuth2Session", + "x-twitter-active-user": "yes", + "content-type": "application/json", + "x-client-transaction-id": tid, + "cookie": f"auth_token={auth}; ct0={ct0}", + "user-agent": ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"), + } + async with httpx.AsyncClient(timeout=30) as h: + r = await h.post(url, json=body, headers=headers) + if r.status_code != 200: + # レスポンス本文はそのまま吐かない (デバッグ用に X の error code/message のみ抽出) + fail(f"x-adapter: home timeline HTTP {r.status_code}{_x_error_suffix(r)}") + try: + payload = r.json() + except Exception: + fail("x-adapter: home timeline returned non-JSON") + # 200 でも GraphQL の errors が返ることがある (認証劣化など)。entries が無く errors が + # あるなら成功偽装せず fail。 + rows = parse_home_timeline(payload, args.max) + if not rows and isinstance(payload, dict) and payload.get("errors"): + msgs = "; ".join(str(e.get("message", "")) for e in payload["errors"] if isinstance(e, dict)) + fail(f"x-adapter: home timeline error: {msgs[:200] or 'unknown'}") + emit(rows) + + +def _x_error_suffix(resp) -> str: + """X のエラーレスポンスから errors[].message だけ安全に取り出す (cookie/header は出さない)。""" + try: + data = resp.json() + errs = data.get("errors") if isinstance(data, dict) else None + if errs: + return ": " + "; ".join(str(e.get("message", "")) for e in errs if isinstance(e, dict))[:200] + except Exception: + pass + return "" + + +def _clamp_max(v) -> int: + try: + n = int(v) + except Exception: + n = 20 + return max(1, min(n, 50)) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="x-adapter", add_help=True) + p.add_argument("--version", action="store_true") + sub = p.add_subparsers(dest="command") + + def common(sp): + sp.add_argument("--max", type=_clamp_max, default=20) + sp.add_argument("--yaml", action="store_true") + sp.add_argument("--full-text", action="store_true") + sp.add_argument("--compact", action="store_true") + + sp = sub.add_parser("search"); sp.add_argument("query"); sp.add_argument("-t", "--tab", default="Latest"); common(sp) + sp = sub.add_parser("user-posts"); sp.add_argument("username"); common(sp) + sp = sub.add_parser("tweet"); sp.add_argument("tweet"); common(sp) + sp = sub.add_parser("feed"); sp.add_argument("-t", "--tab", default="for_you"); common(sp) + return p + + +def main(argv=None) -> None: + import asyncio + args = build_parser().parse_args(argv) + if args.version: + print(VERSION) + return + handlers = { + "search": cmd_search, + "user-posts": cmd_user_posts, + "tweet": cmd_tweet, + "feed": cmd_feed, + } + handler = handlers.get(args.command) + if not handler: + fail("x-adapter: no subcommand. Use search|user-posts|tweet|feed.") + _cleanup_stale_tmp() # 過去の SIGKILL タイムアウトで残った cookie 入り temp を掃除 + try: + asyncio.run(handler(args)) + except SystemExit: + raise + except Exception as e: + fail(f"x-adapter: {type(e).__name__}: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/bridge/dashboard-api.test.ts b/src/bridge/dashboard-api.test.ts index 9f0fd86..f70e48d 100644 --- a/src/bridge/dashboard-api.test.ts +++ b/src/bridge/dashboard-api.test.ts @@ -10,11 +10,12 @@ import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status function makeApp(userId: string, repo: Repository, opts?: { registry?: BackendStatusRegistry | null; + role?: 'admin' | 'user'; }): express.Application { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).user = { id: userId, role: 'user' }; + (req as any).user = { id: userId, role: opts?.role ?? 'user' }; next(); }); app.use( @@ -85,6 +86,21 @@ describe('Dashboard API', () => { expect(serialized).not.toMatch(/instruction|"u1"|"seed"/); }); + it('GET /workers exposes occupants (user + job kind) to admins only', async () => { + const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' }); + const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed', ownerId: alice.id, taskKind: 'reflection' }); + await repo.updateJob(j.id, { status: 'running', workerId: 'w1' }); + + // Non-admin: no owner names / kinds leak. + const asUser = await request(makeApp('u1', repo, { role: 'user' })).get('/api/local/dashboard/workers'); + expect(asUser.body.workers[0].occupants).toBeUndefined(); + expect(JSON.stringify(asUser.body.workers[0])).not.toMatch(/Alice/); + + // Admin: sees who occupies the worker and with what kind of job. + const asAdmin = await request(makeApp('admin1', repo, { role: 'admin' })).get('/api/local/dashboard/workers'); + expect(asAdmin.body.workers[0].occupants).toEqual([{ user: 'Alice', kind: 'reflection' }]); + }); + it('returns 401 when no req.user and authActive=true', async () => { const app = express(); app.use(express.json()); diff --git a/src/bridge/dashboard-api.ts b/src/bridge/dashboard-api.ts index 30fb45c..9043f6d 100644 --- a/src/bridge/dashboard-api.ts +++ b/src/bridge/dashboard-api.ts @@ -52,9 +52,17 @@ export function createDashboardApi(deps: DashboardApiDeps): Router { next(); }); - r.get('/workers', async (_req, res) => { + r.get('/workers', async (req, res) => { try { - const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null); + // Admin-only: surface which users' jobs occupy each worker/GPU. The + // panel is shown to all tenants, so non-admins never get owner names. + const includeOwners = getUser(req)?.role === 'admin'; + const workers = await collectWorkerStatuses( + repo, + getWorkers(), + deps.backendStatusRegistry ?? null, + { includeOwners }, + ); res.json({ workers }); } catch (err) { logger.error(`[dashboard-api] GET /workers failed err=${err}`); diff --git a/src/bridge/dashboard-workers.test.ts b/src/bridge/dashboard-workers.test.ts index 26b4ad0..3c39cab 100644 --- a/src/bridge/dashboard-workers.test.ts +++ b/src/bridge/dashboard-workers.test.ts @@ -158,6 +158,45 @@ describe('collectWorkerStatuses', () => { expect(row.online).toBeUndefined(); }); + it('omits occupants[] by default (privacy — non-admin path)', async () => { + const u = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' }); + const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'seed', ownerId: u.id }); + await repo.updateJob(j.id, { status: 'running', workerId: 'w1' }); + const result = await collectWorkerStatuses(repo, [{ id: 'w1', endpoint: 'x' }]); + expect(result[0]!.occupants).toBeUndefined(); + }); + + it('includes occupants (user + job kind) when includeOwners=true (admin)', async () => { + const alice = repo.createUser({ email: 'a@x', name: 'Alice', role: 'user', status: 'active' }); + const bob = repo.createUser({ email: 'b@x', name: 'Bob', role: 'user', status: 'active' }); + const j1 = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 's', ownerId: alice.id }); + await repo.updateJob(j1.id, { status: 'running', workerId: 'w1' }); + // Bob's job is a reflection run — the kind must surface as the badge tag. + const j2 = await repo.createJob({ repo: 'local/task-2', issueNumber: 2, instruction: 's', ownerId: bob.id, taskKind: 'reflection' }); + await repo.updateJob(j2.id, { status: 'running', workerId: 'w1' }); + // Idle worker w2 gets no occupants entry even with includeOwners. + const result = await collectWorkerStatuses( + repo, + [{ id: 'w1', endpoint: 'x' }, { id: 'w2', endpoint: 'y' }], + null, + { includeOwners: true }, + ); + const w1 = result.find(w => w.id === 'w1')!; + expect([...w1.occupants!].sort((a, b) => a.user.localeCompare(b.user))).toEqual([ + { user: 'Alice', kind: 'agent' }, + { user: 'Bob', kind: 'reflection' }, + ]); + expect(result.find(w => w.id === 'w2')!.occupants).toBeUndefined(); + }); + + it('falls back to owner id when the user row has no display name', async () => { + // Job owned by an id with no matching users row (e.g. legacy / deleted user). + const j = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 's', ownerId: 'ghost-id' }); + await repo.updateJob(j.id, { status: 'running', workerId: 'w1' }); + const result = await collectWorkerStatuses(repo, [{ id: 'w1', endpoint: 'x' }], null, { includeOwners: true }); + expect(result[0]!.occupants).toEqual([{ user: 'ghost-id', kind: 'agent' }]); + }); + it('marks online=false for direct workers when probe failed', async () => { const fakeRegistry = { getAll: () => [{ diff --git a/src/bridge/dashboard-workers.ts b/src/bridge/dashboard-workers.ts index 4ee19d0..ee3cc1a 100644 --- a/src/bridge/dashboard-workers.ts +++ b/src/bridge/dashboard-workers.ts @@ -43,6 +43,16 @@ export interface WorkerStatusRow { * reported". */ backends?: WorkerStatusBackendRow[]; + /** + * Occupants of this worker: the users whose running jobs currently use + * it, each with the job's `kind` (`task_kind`: 'agent' = normal, + * 'reflection' = learning, etc.). **Admin-only** — populated only when + * `collectWorkerStatuses` is called with `includeOwners: true` (the API + * gates this on `req.user.role === 'admin'`). Undefined for non-admins so + * it never reaches a multi-tenant viewer. Empty array is never returned; + * an idle worker simply omits it. + */ + occupants?: Array<{ user: string; kind: string }>; } /** @@ -54,15 +64,24 @@ export interface WorkerStatusRow { * operator can see *which* backend behind a LiteLLM / AAO Gateway * front is currently in use rather than just "the proxy is busy". * - * Privacy: returns idle/running booleans + slot counts only. Never job - * ids, titles, or owners, since the panel is shown to all users in a - * multi-tenant deployment. + * Privacy: by default returns idle/running booleans + slot counts only — + * never job ids, titles, or owners, since the panel is shown to all users + * in a multi-tenant deployment. The single exception is `includeOwners`: + * when true (admin-only — gated by the API on `req.user.role==='admin'`), + * each running worker row also carries `users[]` = display names of the + * users whose jobs occupy it, so an admin can see *who* holds the GPU. */ export async function collectWorkerStatuses( repo: Repository, workers: WorkerDef[], registry: Pick | null = null, + opts?: { includeOwners?: boolean }, ): Promise { + // Admin monitoring: map worker_id → occupying user display names. Built + // once per call; only when the caller opted in (non-admins never get it). + const ownersByWorker = opts?.includeOwners + ? repo.listRunningJobOwnersByWorker() + : null; // Build a workerId → NodeStatus[] map once per call so we don't // O(N*M) the registry snapshot per worker. registry.getAll() copies // its internal cache, so calling it once is cheap. @@ -84,6 +103,10 @@ export async function collectWorkerStatuses( state: repo.isWorkerBusy(w.id) ? 'running' : 'idle', proxy: isProxy, }; + if (ownersByWorker) { + const occupants = ownersByWorker.get(w.id); + if (occupants && occupants.length > 0) row.occupants = occupants; + } if (isProxy && registry) { // Filter to backend-source rows only — the registry also stores a // self-row for the proxy worker itself (source='proxy', nodeId = diff --git a/src/bridge/space-api.transfer.test.ts b/src/bridge/space-api.transfer.test.ts new file mode 100644 index 0000000..03eec27 --- /dev/null +++ b/src/bridge/space-api.transfer.test.ts @@ -0,0 +1,293 @@ +// スペース間資格情報コピー API のテスト。 +// +// 両スペースの管理権を持つ actor のみが candidates 一覧取得と transfer 実行を +// 許可される。片方のみ管理者、または非メンバーは 403。 +// 成功時に audit_log が 1 行増える。 + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Repository } from '../db/repository.js'; +import { runMigrations } from '../db/migrate.js'; +import { createSpaceApi, type SpaceApiDeps } from './space-api.js'; +import { createRegistry } from '../mcp/registry.js'; +import { createConnectionRepo } from '../ssh/connection-repo.js'; +import { encryptPrivateKey, bootstrapSystemDek } from '../ssh/crypto.js'; + +function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User { + return { + id, + email: `${id}@x.com`, + name: id, + avatarUrl: null, + role, + status: 'active', + orgIds, + defaultVisibility: 'private', + defaultVisibilityOrgId: null, + } as unknown as Express.User; +} + +describe('space credential-transfer API', () => { + let dir = ''; + let repo: Repository; + let worktreeDir = ''; + + // ユーザー + let bothOwnerId = ''; // A と B 両方の owner + let onlyBOwnerId = ''; // B のみ owner(A は非メンバー) + let onlyAOwnerId = ''; // A のみ owner(B は非メンバー) + let strangerId = ''; // どちらも非メンバー + + // スペース + let spaceAId = ''; + let spaceBId = ''; + + // MCP サーバー ID(スペース A に seeded) + let mcpServerId = ''; + // SSH 接続 ID(スペース A に seeded) + let sshConnId = ''; + + function appAs(u: Express.User | null, authActive = true): express.Application { + const app = express(); + if (u) { + app.use((req, _res, next) => { + (req as unknown as { user: Express.User }).user = u; + next(); + }); + } + const deps: SpaceApiDeps = { + repo, + dataRoot: join(dir, 'data', 'users'), + worktreeDir, + authActive, + }; + app.use('/api/local/spaces', createSpaceApi(deps)); + return app; + } + + beforeEach(async () => { + process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64); + dir = mkdtempSync(join(tmpdir(), 'space-transfer-')); + worktreeDir = join(dir, 'workspaces'); + repo = new Repository(join(dir, 'db.sqlite')); + // auth_kind 等 migrate.ts で追加されるカラムを適用 + runMigrations(repo.getDb()); + + // システム DEK を初期化(SSH 暗号化に必要) + bootstrapSystemDek(repo.getDb()); + + // ユーザー作成 + bothOwnerId = repo.createUser({ email: 'both@x.com', name: 'both', role: 'user', status: 'active' }).id; + onlyBOwnerId = repo.createUser({ email: 'onlyb@x.com', name: 'onlyb', role: 'user', status: 'active' }).id; + onlyAOwnerId = repo.createUser({ email: 'onlya@x.com', name: 'onlya', role: 'user', status: 'active' }).id; + strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id; + + // スペース A(bothOwner と onlyAOwner が owner) + const spaceA = await repo.createSpace({ kind: 'case', title: 'Space A', ownerId: bothOwnerId, visibility: 'private' }); + spaceAId = spaceA.id; + await repo.addSpaceMember({ spaceId: spaceAId, userId: onlyAOwnerId, role: 'owner', invitedBy: bothOwnerId }); + + // スペース B(bothOwner と onlyBOwner が owner) + const spaceB = await repo.createSpace({ kind: 'case', title: 'Space B', ownerId: bothOwnerId, visibility: 'private' }); + spaceBId = spaceB.id; + await repo.addSpaceMember({ spaceId: spaceBId, userId: onlyBOwnerId, role: 'owner', invitedBy: bothOwnerId }); + + // MCP サーバーをスペース A に追加 + const db = repo.getDb(); + const reg = createRegistry(db); + reg.upsert({ + id: 'mcp-test-1', + name: 'TestServer', + url: 'https://test.example', + authKind: 'api_key', + staticToken: 'secret-token', + ownerId: null, + spaceId: spaceAId, + }); + mcpServerId = 'mcp-test-1'; + + // SSH 接続をスペース A に追加 + const connRepo = createConnectionRepo(db); + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEY\n-----END OPENSSH PRIVATE KEY-----'; + const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), spaceAId); + const sshConn = connRepo.create({ + ownerId: null, + spaceId: spaceAId, + label: 'prod-server', + host: 'host.example', + port: 22, + username: 'deploy', + privateKeyEnc: blob, + passphraseEnc: null, + keyVersion, + keyFingerprint: null, + remotePathPrefix: '/srv', + allowRemoteUnrestricted: false, + allowPrivateAddresses: false, + commandDenyPatterns: null, + commandAllowPatterns: null, + }); + sshConnId = sshConn.id; + }); + + afterEach(() => { + repo.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + // ─── GET /candidates ───────────────────────────────────────────── + + describe('GET /:targetId/transfer/candidates', () => { + it('両スペースを管理できる owner は 200 で mcp+ssh 候補一覧を受け取る', async () => { + const res = await request(appAs(user(bothOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceAId }); + expect(res.status).toBe(200); + expect(res.body.mcp).toHaveLength(1); + expect(res.body.mcp[0]).toMatchObject({ type: 'mcp', name: 'TestServer', willSkip: false }); + expect(res.body.ssh).toHaveLength(1); + expect(res.body.ssh[0]).toMatchObject({ type: 'ssh', name: 'prod-server', willSkip: false }); + }); + + it('sourceId がなければ 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`); + expect(res.status).toBe(400); + }); + + it('sourceId === targetId は 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceBId }); + expect(res.status).toBe(400); + }); + + it('target のみ管理・source 非管理のユーザーは 403', async () => { + const res = await request(appAs(user(onlyBOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceAId }); + expect(res.status).toBe(403); + }); + + it('source のみ管理・target 非管理のユーザーは 403', async () => { + const res = await request(appAs(user(onlyAOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceAId }); + expect(res.status).toBe(403); + }); + + it('どちらも非メンバーは 403', async () => { + const res = await request(appAs(user(strangerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceAId }); + expect(res.status).toBe(403); + }); + }); + + // ─── POST /transfer ────────────────────────────────────────────── + + describe('POST /:targetId/transfer', () => { + it('両スペースを管理できる owner は 200 で copied を受け取る', async () => { + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [mcpServerId], sshConnectionIds: [sshConnId] }); + expect(res.status).toBe(200); + expect(res.body.copied).toHaveLength(2); + expect(res.body.skipped).toHaveLength(0); + }); + + it('sourceId がなければ 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ mcpServerIds: [], sshConnectionIds: [] }); + expect(res.status).toBe(400); + }); + + it('sourceId === targetId は 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceBId, mcpServerIds: [], sshConnectionIds: [] }); + expect(res.status).toBe(400); + }); + + it('mcpServerIds が配列でなければ 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: 'bad', sshConnectionIds: [] }); + expect(res.status).toBe(400); + }); + + it('sshConnectionIds が配列でなければ 400', async () => { + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [], sshConnectionIds: 'bad' }); + expect(res.status).toBe(400); + }); + + it('target のみ管理・source 非管理のユーザーは 403', async () => { + const res = await request(appAs(user(onlyBOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [], sshConnectionIds: [] }); + expect(res.status).toBe(403); + }); + + it('source のみ管理・target 非管理のユーザーは 403', async () => { + const res = await request(appAs(user(onlyAOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [], sshConnectionIds: [] }); + expect(res.status).toBe(403); + }); + + it('どちらも非メンバーは 403', async () => { + const res = await request(appAs(user(strangerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [], sshConnectionIds: [] }); + expect(res.status).toBe(403); + }); + + it('POST 成功で audit_log が 1 行増える', async () => { + const db = repo.getDb(); + const before = (db.prepare('SELECT COUNT(*) AS n FROM audit_log').get() as { n: number }).n; + + const res = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [mcpServerId], sshConnectionIds: [] }); + expect(res.status).toBe(200); + + const after = (db.prepare('SELECT COUNT(*) AS n FROM audit_log').get() as { n: number }).n; + expect(after).toBe(before + 1); + + // 監査ログの内容確認 + const row = db.prepare("SELECT action, actor, detail FROM audit_log ORDER BY id DESC LIMIT 1").get() as { + action: string; actor: string; detail: string; + }; + expect(row.action).toBe('space.credential.transfer'); + expect(row.actor).toBe(bothOwnerId); + const detail = JSON.parse(row.detail) as Record; + expect(detail.sourceId).toBe(spaceAId); + expect(detail.targetId).toBe(spaceBId); + }); + + it('候補・転送レスポンスにシークレットを出さない (Fix 4)', async () => { + // GET candidates + const candRes = await request(appAs(user(bothOwnerId))) + .get(`/api/local/spaces/${spaceBId}/transfer/candidates`) + .query({ sourceId: spaceAId }); + expect(candRes.status).toBe(200); + expect(JSON.stringify(candRes.body)).not.toContain('secret-token'); + expect(JSON.stringify(candRes.body)).not.toContain('FAKEKEY'); + + // POST transfer + const transferRes = await request(appAs(user(bothOwnerId))) + .post(`/api/local/spaces/${spaceBId}/transfer`) + .send({ sourceId: spaceAId, mcpServerIds: [mcpServerId], sshConnectionIds: [sshConnId] }); + expect(transferRes.status).toBe(200); + expect(JSON.stringify(transferRes.body)).not.toContain('secret-token'); + expect(JSON.stringify(transferRes.body)).not.toContain('FAKEKEY'); + }); + }); +}); diff --git a/src/bridge/space-api.ts b/src/bridge/space-api.ts index ec914e0..38032cf 100644 --- a/src/bridge/space-api.ts +++ b/src/bridge/space-api.ts @@ -12,6 +12,17 @@ import { registerSpaceAppShareRoutes } from './space-app-share-api.js'; import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js'; import { viewerOf } from './space-viewer.js'; import { logger } from '../logger.js'; +import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js'; +import { parseToolPolicy } from '../engine/workspace-tool-policy.js'; +import { + listToolCategories, + SENSITIVE_CATEGORIES, + SENSITIVE_TOOLS, +} from '../engine/tools/tool-categories.js'; +import { + listTransferCandidates, + executeTransfer, +} from '../services/space-credential-transfer.js'; // cross-calendar-api.ts は parseTzOffset / monthBounds を space-api 経由で参照する。 // 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、 @@ -147,5 +158,86 @@ export function createSpaceApi(deps: SpaceApiDeps): Router { // ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。 registerSpaceToolPolicyRoutes(router, deps); + // ─── スペース間資格情報コピー ────────────────────────────────────── + // 両スペースの管理権(canManageSpace)を持つユーザーのみ利用可能。 + // 成功時に audit_log へ記録する。 + + /** 閲覧者が source・target 両スペースを管理できるかを確認する。 */ + async function canManageBothSpaces( + viewer: Express.User, + sourceId: string, + targetId: string, + ): Promise { + const source = await repo.getSpace(sourceId, { viewer }); + const target = await repo.getSpace(targetId, { viewer }); + if (!source || !target) return false; + const srcRole = repo.getSpaceMemberRole(sourceId, viewer.id); + const tgtRole = repo.getSpaceMemberRole(targetId, viewer.id); + return ( + canManageSpace(viewer, source, srcRole) && + canManageSpace(viewer, target, tgtRole) + ); + } + + // GET /:targetId/transfer/candidates?sourceId= + // 元スペースの MCP サーバー・SSH 接続の転送候補一覧を返す。 + router.get('/:targetId/transfer/candidates', async (req, res) => { + const viewer = viewerOf(req, deps.authActive); + const targetId = req.params.targetId; + const sourceId = String(req.query.sourceId ?? ''); + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' }); + if (!(await canManageBothSpaces(viewer, sourceId, targetId))) { + return res.status(403).json({ error: 'forbidden' }); + } + const db = repo.getDb(); + return res.json(listTransferCandidates({ db }, sourceId, targetId)); + }); + + // POST /:targetId/transfer + // body: { sourceId, mcpServerIds: string[], sshConnectionIds: string[] } + // 指定した MCP サーバー・SSH 接続を転送し、copied/skipped サマリを返す。 + router.post('/:targetId/transfer', jsonParser, async (req, res) => { + const viewer = viewerOf(req, deps.authActive); + const targetId = req.params.targetId; + const sourceId = String(req.body?.sourceId ?? ''); + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' }); + + const mcpServerIds = req.body?.mcpServerIds; + const sshConnectionIds = req.body?.sshConnectionIds; + if (mcpServerIds !== undefined && !Array.isArray(mcpServerIds)) { + return res.status(400).json({ error: 'mcpServerIds must be an array' }); + } + if (sshConnectionIds !== undefined && !Array.isArray(sshConnectionIds)) { + return res.status(400).json({ error: 'sshConnectionIds must be an array' }); + } + + if (!(await canManageBothSpaces(viewer, sourceId, targetId))) { + return res.status(403).json({ error: 'forbidden' }); + } + + const db = repo.getDb(); + const result = executeTransfer( + { db }, + { + sourceSpaceId: sourceId, + targetSpaceId: targetId, + actorId: viewer.id, + mcpServerIds: (mcpServerIds as string[]) ?? [], + sshConnectionIds: (sshConnectionIds as string[]) ?? [], + }, + ); + + await deps.repo.addAuditLog(null, 'space.credential.transfer', viewer.id, { + sourceId, + targetId, + copied: result.copied.length, + skipped: result.skipped.length, + }); + + return res.json(result); + }); + return router; } diff --git a/src/config.test.ts b/src/config.test.ts index 8855608..26bbf70 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -211,6 +211,7 @@ function makeValidConfig(): AppConfig { subtasks: { maxDepth: 2, maxPerParent: 10, + spawnStaggerMs: 1000, }, safety: { maxIterations: 200, @@ -320,6 +321,32 @@ describe('validateConfig', () => { expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(false); }); + it('defaults subtasks.spawnStaggerMs to 1000 when not configured', () => { + const config = loadConfig(join(tmpdir(), 'missing-config.yaml')); + expect(config.subtasks.spawnStaggerMs).toBe(1000); + }); + + it('invalid subtasks.spawnStaggerMs (-1) produces error', () => { + const config = makeValidConfig(); + config.subtasks.spawnStaggerMs = -1; + const errors = validateConfig(config); + expect(errors.some(e => e.includes('subtasks.spawnStaggerMs'))).toBe(true); + }); + + it('invalid subtasks.spawnStaggerMs (non-integer) produces error', () => { + const config = makeValidConfig(); + config.subtasks.spawnStaggerMs = 1.5; + const errors = validateConfig(config); + expect(errors.some(e => e.includes('subtasks.spawnStaggerMs'))).toBe(true); + }); + + it('subtasks.spawnStaggerMs (0) is valid', () => { + const config = makeValidConfig(); + config.subtasks.spawnStaggerMs = 0; + const errors = validateConfig(config); + expect(errors.some(e => e.includes('subtasks.spawnStaggerMs'))).toBe(false); + }); + it('invalid retry.maxAttempts (0) produces error', () => { const config = makeValidConfig(); config.retry.maxAttempts = 0; diff --git a/src/config.ts b/src/config.ts index e819e0d..cc33b28 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,7 @@ export interface AskConfig { export interface SubtasksConfig { maxDepth: number; // default: 2 (0 = no decomposition) maxPerParent: number; // default: 10 (max subtasks a single job can spawn) + spawnStaggerMs: number; // default: 1000 (delay between SpawnSubTask enqueues so a burst doesn't jump GPU priority; 0 disables) } export interface ToolsConfig { @@ -555,6 +556,7 @@ const defaults: AppConfig = { subtasks: { maxDepth: 2, maxPerParent: 10, + spawnStaggerMs: 1000, }, tools: { searxngUrl: 'http://searxng:8080', @@ -902,6 +904,9 @@ export function validateConfig(config: AppConfig): string[] { if (!Number.isInteger(config.subtasks.maxPerParent) || config.subtasks.maxPerParent < 1) { errors.push('subtasks.maxPerParent must be a positive integer'); } + if (!Number.isInteger(config.subtasks.spawnStaggerMs) || config.subtasks.spawnStaggerMs < 0) { + errors.push('subtasks.spawnStaggerMs must be a non-negative integer'); + } if (!Number.isInteger(config.retry.maxAttempts) || config.retry.maxAttempts <= 0) { errors.push('retry.maxAttempts must be a positive integer'); diff --git a/src/db/repository.test.ts b/src/db/repository.test.ts index 9713f74..6f10530 100644 --- a/src/db/repository.test.ts +++ b/src/db/repository.test.ts @@ -1793,3 +1793,71 @@ describe('Repository.createJobIfNoPending', () => { } finally { repo.close(); } }); }); + +describe('Repository.requeueParentJobIfAllSubtasksDone', () => { + let tempDir = ''; + afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); + function makeRepo(): Repository { + tempDir = mkdtempSync(join(tmpdir(), 'maestro-requeue-')); + return new Repository(join(tempDir, 'orchestrator.db')); + } + + async function makeParent(repo: Repository, status: 'waiting_subtasks' | 'running'): Promise { + const parent = await repo.createJob({ + repo: 'local/task-1', issueNumber: 1, instruction: 'parent', pieceName: 'general', + }); + await repo.updateJob(parent.id, { status }); + return parent.id; + } + + async function addChild(repo: Repository, parentId: string, issueNumber: number, status: string): Promise { + const child = await repo.createJob({ + repo: `subtask/${parentId}`, issueNumber, instruction: `sub ${issueNumber}`, + pieceName: 'general', parentJobId: parentId, subtaskDepth: 1, + }); + await repo.updateJob(child.id, { status: status as never }); + return child.id; + } + + it('requeues the parent when all latest-per-issue children are terminal', async () => { + const repo = makeRepo(); + try { + const parentId = await makeParent(repo, 'waiting_subtasks'); + await addChild(repo, parentId, 1, 'succeeded'); + await addChild(repo, parentId, 2, 'failed'); + + const requeued = await repo.requeueParentJobIfAllSubtasksDone(parentId); + expect(requeued).toBe(true); + const parent = await repo.getJob(parentId); + expect(parent?.status).toBe('queued'); + } finally { repo.close(); } + }); + + it('does not requeue while any child is still non-terminal', async () => { + const repo = makeRepo(); + try { + const parentId = await makeParent(repo, 'waiting_subtasks'); + await addChild(repo, parentId, 1, 'succeeded'); + await addChild(repo, parentId, 2, 'running'); + + const requeued = await repo.requeueParentJobIfAllSubtasksDone(parentId); + expect(requeued).toBe(false); + const parent = await repo.getJob(parentId); + expect(parent?.status).toBe('waiting_subtasks'); + } finally { repo.close(); } + }); + + it('does not requeue when the parent is not in waiting_subtasks', async () => { + const repo = makeRepo(); + try { + const parentId = await makeParent(repo, 'running'); + await addChild(repo, parentId, 1, 'succeeded'); + await addChild(repo, parentId, 2, 'succeeded'); + + const requeued = await repo.requeueParentJobIfAllSubtasksDone(parentId); + expect(requeued).toBe(false); + const parent = await repo.getJob(parentId); + expect(parent?.status).toBe('running'); + } finally { repo.close(); } + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index 2b943a1..53ceb1e 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -2814,6 +2814,39 @@ export class Repository { 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 ワーカーに複数ユーザー / + * 複数種別が同居するケースに対応する。 + */ + listRunningJobOwnersByWorker(): Map> { + const rows = this.db + .prepare( + `SELECT j.worker_id AS workerId, j.owner_id AS ownerId, j.task_kind AS taskKind, u.name AS ownerName + FROM jobs j + LEFT JOIN users u ON u.id = j.owner_id + WHERE j.status = 'running' AND j.worker_id IS NOT NULL`, + ) + .all() as Array<{ workerId: string; ownerId: string | null; taskKind: string | null; ownerName: string | null }>; + const byWorker = new Map>(); + for (const r of rows) { + const user = (r.ownerName && r.ownerName.trim()) || r.ownerId || 'unknown'; + const kind = r.taskKind || 'agent'; + const list = byWorker.get(r.workerId); + if (list) { + if (!list.some((o) => o.user === user && o.kind === kind)) list.push({ user, kind }); + } else { + byWorker.set(r.workerId, [{ user, kind }]); + } + } + return byWorker; + } + async updateJob(id: string, updates: Partial>): Promise { const setClauses: string[] = ["updated_at = datetime('now')"]; const params: Record = { id }; diff --git a/src/engine/agent-loop.ts b/src/engine/agent-loop.ts index 90171a0..7673010 100644 --- a/src/engine/agent-loop.ts +++ b/src/engine/agent-loop.ts @@ -67,6 +67,10 @@ export interface MovementResult { lessons?: string | null; // このステップで得た教訓 waitReason?: string | null; // waiting_human の場合の待機理由(例: 'browser_login') browserSessionId?: string | null; // InteractiveBrowse で確保したセッションID + // next='WAIT_SUBTASKS'(WaitSubTask ツール / complete 時の自動停車)で、再開する + // movement 名を運ぶ。未指定なら piece-runner が movementDef.default_next に落とす。 + // WaitSubTask は現在の movement 名を入れ、子完了後に同じステップへ再入させる。 + resumeMovement?: string | null; // next='ABORT' のときに、どの経路で abort したかを示す細分コード。 // piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら // 'movement_abort'(後方互換)。 @@ -2426,6 +2430,21 @@ export async function executeMovement( waitReason: 'tool_request', }); } + // WaitSubTask: the tool 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 resumes the SAME movement (resumeMovement) with subtasks/*/ + // result.md present. Park signal, NOT an in-tool block (worker-release is + // load-bearing vs deadlock). Mirrors the pendingToolApproval handling above. + if (toolCtx.pendingSubtaskWait) { + toolCtx.pendingSubtaskWait = false; + logger.info(`[agent-loop] movement=${movement.name} WaitSubTask → WAIT_SUBTASKS (resume same movement)`); + return finishMovement({ + next: 'WAIT_SUBTASKS', + output: 'サブタスクの完了を待機しています。', + toolsUsed, + resumeMovement: movement.name, + }); + } regularToolsUsed += dispatch.regularToolsUsedDelta; const pendingImages = dispatch.pendingImages; @@ -2471,6 +2490,29 @@ export async function executeMovement( const winner = selectTerminalWinner(classified); if (winner.kind === 'native_winner') { + // Auto-park guard: the agent tried to complete *successfully* while it + // still has unwaited pending children. Convert to WAIT_SUBTASKS instead + // of a false success, resuming the SAME movement so it can read + // subtasks/*/result.md and genuinely finish. Only the success path is + // parked here; abort / needs_user_input with pending children is handled + // at the worker terminal chokepoint (cancelPendingSubtasks). + if ( + winner.args.status === 'success' && + toolCtx.hasPendingSubtasks && + // Fail-open: a DB hiccup in the pending-children check must NOT turn a + // genuine completion into a job failure (the WaitSubTask tool path is + // try/catch-guarded; this inline call is not). Default to "no pending" + // so the real complete goes through. + (await toolCtx.hasPendingSubtasks().catch(() => false)) + ) { + logger.info(`[agent-loop] movement=${movement.name} complete(success) with pending subtasks → auto-park WAIT_SUBTASKS`); + return finishMovement({ + next: 'WAIT_SUBTASKS', + output: winner.args.result ?? 'サブタスクの完了を待機しています。', + toolsUsed, + resumeMovement: movement.name, + }); + } const result = buildMovementResultFromComplete( winner.args, toolsUsed, diff --git a/src/engine/piece-runner.test.ts b/src/engine/piece-runner.test.ts index 616358c..a8a1bd0 100644 --- a/src/engine/piece-runner.test.ts +++ b/src/engine/piece-runner.test.ts @@ -671,6 +671,92 @@ describe('buildFollowupNotice (option C)', () => { }); }); +import { buildSubtaskResumeNotice } from './piece-runner.js'; + +describe('buildSubtaskResumeNotice', () => { + let workspace: string; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), 'subtask-resume-test-')); + }); + + afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + it('returns empty string when subtasks/ does not exist', () => { + expect(buildSubtaskResumeNotice(workspace)).toBe(''); + }); + + it('returns empty string when subtask dirs have no result.md', () => { + mkdirSync(join(workspace, 'subtasks', '1'), { recursive: true }); + writeFileSync(join(workspace, 'subtasks', '1', 'notes.txt'), 'x', 'utf-8'); + expect(buildSubtaskResumeNotice(workspace)).toBe(''); + }); + + it('builds a notice mentioning the completed count when result.md files exist', () => { + mkdirSync(join(workspace, 'subtasks', '1'), { recursive: true }); + mkdirSync(join(workspace, 'subtasks', '2'), { recursive: true }); + writeFileSync(join(workspace, 'subtasks', '1', 'result.md'), 'done 1', 'utf-8'); + writeFileSync(join(workspace, 'subtasks', '2', 'result.md'), 'done 2', 'utf-8'); + const notice = buildSubtaskResumeNotice(workspace); + expect(notice).not.toBe(''); + expect(notice).toContain('サブタスク完了'); + expect(notice).toContain('result.md'); + expect(notice).toContain('2 件'); + }); +}); + +describe('runPiece WAIT_SUBTASKS resumeMovement', () => { + let workspace: string; + + beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), 'wait-resume-test-')); + }); + + afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); + }); + + function makeWaitPiece(): PieceDef { + return { + name: 'wait-piece', + description: 'd', + max_movements: 1, + initial_movement: 'm', + movements: [{ + name: 'm', + edit: false, + persona: 'p', + instruction: 'i', + allowed_tools: [], + rules: [{ condition: 'spawned', next: 'WAIT_SUBTASKS' }], + default_next: 'COMPLETE', + }], + }; + } + + it('honors a tool-supplied resumeMovement over default_next', async () => { + executeMovementMock.mockResolvedValue({ + next: 'WAIT_SUBTASKS', output: 'spawned children', toolsUsed: [], resumeMovement: 'respond', + } as MovementResult); + const result = await runPiece(makeWaitPiece(), 'task', {} as never, workspace); + expect(result.status).toBe('waiting_subtasks'); + expect(result.resumeMovement).toBe('respond'); + }); + + it('falls back to default_next when no resumeMovement is supplied', async () => { + const piece = makeWaitPiece(); + piece.movements[0].default_next = 'aggregate'; + executeMovementMock.mockResolvedValue({ + next: 'WAIT_SUBTASKS', output: 'spawned children', toolsUsed: [], + } as MovementResult); + const result = await runPiece(piece, 'task', {} as never, workspace); + expect(result.status).toBe('waiting_subtasks'); + expect(result.resumeMovement).toBe('aggregate'); + }); +}); + // ============================================================ // Traceability T-2 — handoff / delta / followup / context_action // ============================================================ diff --git a/src/engine/piece-runner.ts b/src/engine/piece-runner.ts index 5166710..2c1a444 100644 --- a/src/engine/piece-runner.ts +++ b/src/engine/piece-runner.ts @@ -386,6 +386,7 @@ export async function runPiece( askCount?: number; maxAskPerJob?: number; spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; + hasPendingSubtasks?: () => Promise; cancelCheck?: () => boolean; abortController?: AbortController; safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean }; @@ -477,6 +478,12 @@ export async function runPiece( * as the gate for every movement. When absent, falls back to safe defaults * (resolveWorkspaceTools({})). Resolved once per job by the worker. */ workspaceTools?: { allowedTools: string[]; editAllowed: boolean }; + /** SSH workspace scoping: connection IDs registered to this job's space, + * resolved by the worker when 'ssh' is enabled in the workspace tool policy. + * When provided, overrides movement.allowed_ssh_connections as the SSH gate. + * undefined = ssh not enabled in policy OR no subsystem (fallback to movement). + * Empty array = ssh enabled but no connections registered → clean rejection. */ + workspaceSshConnections?: string[]; /** Tool-request mechanism: true when this run can pause for inline approval * (a reachable user — local task, not a subtask/headless run). */ toolApprovalInteractive?: boolean; @@ -792,6 +799,7 @@ function prepareMovementContext( callbacks: PieceRunCallbacks | undefined, options?: { spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; + hasPendingSubtasks?: () => Promise; searchFilter?: SearchFilterConfig; customPiecesDir?: string | string[]; vlmEnabled?: boolean; @@ -847,6 +855,13 @@ function prepareMovementContext( * edit flag for this job's space. When provided, replaces movement.allowed_tools * and movementDef.edit as the gate for this movement. */ workspaceTools?: { allowedTools: string[]; editAllowed: boolean }; + /** SSH workspace scoping: connection IDs registered to this job's space. + * When provided (by the worker when ssh is enabled in the workspace policy), + * overrides movement.allowed_ssh_connections as the authoritative SSH gate. + * undefined = ssh not enabled in policy OR legacy/unit-test caller (falls + * back to movement declaration). Empty array = ssh enabled but no connections + * registered → clean "not in allowed list" rejection from ssh.ts preflight. */ + workspaceSshConnections?: string[]; /** Tool-request mechanism: true when this run can pause for inline approval * (a reachable user — local task, not a subtask/headless run). */ toolApprovalInteractive?: boolean; @@ -883,7 +898,9 @@ function prepareMovementContext( // movement.allowed_tools. grantedTools (inline approvals) always union in. // META_TOOLS are added downstream by getToolDefs — not double-added here. allowedTools: effectiveAllowedTools, - allowedSshConnections: movementDef.allowed_ssh_connections, + allowedSshConnections: options?.workspaceSshConnections !== undefined + ? options.workspaceSshConnections + : movementDef.allowed_ssh_connections, rules: movementDef.rules, defaultNext: movementDef.default_next, }; @@ -899,7 +916,12 @@ function prepareMovementContext( bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork, skillCatalog: options?.skillCatalog, userRole: options?.userRole, - allowedSshConnections: movementDef.allowed_ssh_connections, + // SSH gate: workspace-scoped list governs when the worker resolved one + // (ssh enabled in policy). Falls back to movement declaration for legacy / + // unit-test callers that don't supply workspaceSshConnections. + allowedSshConnections: options?.workspaceSshConnections !== undefined + ? options.workspaceSshConnections + : movementDef.allowed_ssh_connections, pieceName: piece.name, // Tool-request mechanism: movement identity + effective tool set for // RequestTool classification, and a recorder wrapped to inject piece + @@ -922,6 +944,7 @@ function prepareMovementContext( // resolution. Phase 1 no-op (no consumer reads ctx.spaceId yet). spaceId: options?.spaceId, spawnSubTask: options?.spawnSubTask, + hasPendingSubtasks: options?.hasPendingSubtasks, missionBrief: options?.missionBrief, taskId: options?.taskId, // No-auth jobs have no owner (ownerId/userId null). Resolve a single 'local' @@ -957,6 +980,15 @@ function prepareMovementContext( movementName: movementDef.name, }); } + // P1-2: when completed subtask results are present (subtasks/N/result.md, written + // by the worker on child completion), this run is resuming after a WAIT_SUBTASKS + // park. The resumed movement re-runs from the original instruction, which would + // otherwise tempt a second identical SpawnSubTask wave. Steer to "read results, + // don't re-spawn". + const subtaskResumeNotice = buildSubtaskResumeNotice(workspacePath); + if (subtaskResumeNotice) { + enrichedInstruction += '\n\n' + subtaskResumeNotice; + } const checklistContext = buildChecklistContext(options?.runtimeDir ?? join(workspacePath, 'logs')); if (checklistContext) { enrichedInstruction += '\n\n' + checklistContext; @@ -969,7 +1001,7 @@ function prepareMovementContext( piece.movements.some((m) => m.allowed_tools.includes('SpawnSubTask')) || (piece.shared_tools?.includes('SpawnSubTask') ?? false); if (!options?.spawnSubTask && pieceUsesSpawn) { - enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できません。decompose ではなく dig を選択してください。'; + enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できません(これ以上の分解は不可)。delegate を使うか、自分で作業を完了させてください。'; } if (lessonsAccumulator.length > 0) { enrichedInstruction += '\n\n' + buildLessonsContext(lessonsAccumulator); @@ -1189,14 +1221,19 @@ function mapMovementResult( } if (result.next === 'WAIT_SUBTASKS') { - logger.info(`[piece-runner] piece=${piece.name} WAIT_SUBTASKS`); + // Honor a tool-supplied resume target: WaitSubTask and the complete-time + // auto-park set resumeMovement to the CURRENT movement so the agent re-enters + // it after children finish (subtasks/*/result.md present). Fall back to + // default_next for the legacy transition-rule path (research decompose → aggregate). + const resumeMovement = result.resumeMovement ?? movementDef.default_next ?? null; + logger.info(`[piece-runner] piece=${piece.name} WAIT_SUBTASKS resume=${resumeMovement}`); return { kind: 'done', result: { status: 'waiting_subtasks', finalOutput: result.output, movementHistory, - resumeMovement: movementDef.default_next ?? null, + resumeMovement, abortReason: null, contextActions, }, @@ -1412,6 +1449,34 @@ export function buildFollowupNotice(workspacePath: string): string { ].join('\n'); } +/** + * When this job's workspace has completed subtask results (subtasks/N/result.md, + * written by the worker when a child reaches a terminal state), the run is almost + * certainly resuming after a WAIT_SUBTASKS park. The resumed movement re-runs from + * the original instruction, which would otherwise tempt a second identical + * SpawnSubTask wave (codex P1-2). Steer the LLM to read the results and synthesize. + */ +export function buildSubtaskResumeNotice(workspacePath: string): string { + const subtasksDir = join(workspacePath, 'subtasks'); + if (!existsSync(subtasksDir)) return ''; + let done = 0; + try { + for (const entry of readdirSync(subtasksDir)) { + if (existsSync(join(subtasksDir, entry, 'result.md'))) done += 1; + } + } catch { + return ''; + } + if (done === 0) return ''; + return [ + '## 【サブタスク完了】 (resumed after WAIT_SUBTASKS)', + `${done} 件のサブタスク結果が subtasks/*/result.md に揃っています。`, + '- まず各 result.md を読んで統合してください', + '- 同じ依頼で SpawnSubTask を再度呼ばないこと(既に実行済み)。追加の分解が本当に必要な場合のみ新規に spawn する', + '- 統合できたら complete で回答を返してください', + ].join('\n'); +} + /** * workspace/logs/checklists/ にあるチェックシートを読み込み、 * movement 開始時に system prompt に注入するための要約テキストを生成する。 diff --git a/src/engine/tools/core.ts b/src/engine/tools/core.ts index 0c506c6..420bf55 100644 --- a/src/engine/tools/core.ts +++ b/src/engine/tools/core.ts @@ -115,6 +115,11 @@ export interface ToolContext { mcpQuotaState?: { files: number; bytes: number }; runIsolatedLlm?: (messages: Message[]) => Promise; spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; + /** + * WaitSubTask: このジョブに未完了(非 terminal)の子サブタスクが残っているか。 + * worker が getSubJobs ベースで実装する。未配線(サブタスク非対応経路)なら undefined。 + */ + hasPendingSubtasks?: () => Promise; /** * 相 B: 直列・文脈節約型サブエージェント実行。delegate ツールから呼ぶ。 * 親ループ内で同期的にサブエージェントを完了まで走らせ最終結果のみ返す。 @@ -212,6 +217,12 @@ export interface ToolContext { * for inline approval of the named tool. Read + reset by agent-loop. */ pendingToolApproval?: { toolName: string } | null; + /** + * Set by WaitSubTask to signal the movement loop to park (waiting_subtasks) + * until this job's spawned children finish. Read + reset by agent-loop, which + * then resumes the SAME movement. Mirrors pendingToolApproval. + */ + pendingSubtaskWait?: boolean; /** 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. */ diff --git a/src/engine/tools/docs.ts b/src/engine/tools/docs.ts index 3e12eb4..da91e8b 100644 --- a/src/engine/tools/docs.ts +++ b/src/engine/tools/docs.ts @@ -100,6 +100,8 @@ const TOOL_DOC_ALIASES: Record = { appbridge: 'workspace-apps', // TestWorkspaceApp (testworkspaceapp.md) testworkspaceapp: 'testworkspaceapp', + // orchestration.ts: WaitSubTask は spawnsubtask.md にまとめる + waitsubtask: 'spawnsubtask', }; const READ_TOOL_DOC_DEF: ToolDef = { diff --git a/src/engine/tools/orchestration.test.ts b/src/engine/tools/orchestration.test.ts index 9e1f587..3367e31 100644 --- a/src/engine/tools/orchestration.test.ts +++ b/src/engine/tools/orchestration.test.ts @@ -151,3 +151,39 @@ describe('delegate tool', () => { expect(res?.isError).toBe(true); }); }); + +describe('WaitSubTask tool', () => { + it('is registered in TOOL_DEFS as an argless tool', () => { + const def = TOOL_DEFS['WaitSubTask']; + expect(def?.function.name).toBe('WaitSubTask'); + expect(def?.function.parameters?.['properties']).toEqual({}); + expect(def?.function.parameters?.['required']).toEqual([]); + }); + + it('errors when the context cannot wait for subtasks (no hasPendingSubtasks)', async () => { + const ctx = makeCtx({ hasPendingSubtasks: undefined } as Partial); + const res = await executeTool('WaitSubTask', {}, ctx); + expect(res?.isError).toBe(true); + expect(res?.output).toContain('使用できません'); + expect(ctx.pendingSubtaskWait).toBeUndefined(); + }); + + it('does not park when there are no pending subtasks', async () => { + const hasPendingSubtasks = vi.fn().mockResolvedValue(false); + const ctx = makeCtx({ hasPendingSubtasks } as Partial); + const res = await executeTool('WaitSubTask', {}, ctx); + expect(hasPendingSubtasks).toHaveBeenCalledTimes(1); + expect(res?.isError).toBe(false); + expect(res?.output).toContain('未完了サブタスクはありません'); + expect(ctx.pendingSubtaskWait).toBeUndefined(); + }); + + it('sets the park flag when subtasks are still pending', async () => { + const hasPendingSubtasks = vi.fn().mockResolvedValue(true); + const ctx = makeCtx({ hasPendingSubtasks } as Partial); + const res = await executeTool('WaitSubTask', {}, ctx); + expect(hasPendingSubtasks).toHaveBeenCalledTimes(1); + expect(res?.isError).toBe(false); + expect(ctx.pendingSubtaskWait).toBe(true); + }); +}); diff --git a/src/engine/tools/orchestration.ts b/src/engine/tools/orchestration.ts index 814de67..21311c0 100644 --- a/src/engine/tools/orchestration.ts +++ b/src/engine/tools/orchestration.ts @@ -37,13 +37,25 @@ export const TOOL_DEFS: Record = { }, piece: { type: 'string', - description: '使用するピース名(general, research, brainstorming, orchestrated, data-process, office-process)。デフォルトは general。', + description: '使用するピース名(general, research, brainstorming, data-process, office-process)。デフォルトは general。', }, }, required: ['title', 'instruction'], }, }, }, + WaitSubTask: { + type: 'function', + function: { + name: 'WaitSubTask', + description: 'SpawnSubTask で起動した全サブタスクの完了を待ちます(引数なし)。並列起動したあとに1回呼ぶと全完了まで停車し、完了後に subtasks/ へ結果が揃った状態で同じステップに再開します。詳細は ReadToolDoc({ name: "SpawnSubTask" })。', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, + }, }; export async function executeTool( @@ -51,7 +63,28 @@ export async function executeTool( input: Record, ctx: ToolContext, ): Promise { - if (name !== 'SpawnSubTask' && name !== 'delegate') return null; + if (name !== 'SpawnSubTask' && name !== 'delegate' && name !== 'WaitSubTask') return null; + + if (name === 'WaitSubTask') { + if (!ctx.hasPendingSubtasks) { + return { output: 'WaitSubTask はこのコンテキストでは使用できません(サブタスク非対応の実行経路)。', isError: true }; + } + const hasPending = await ctx.hasPendingSubtasks(); + if (!hasPending) { + return { + output: '待機対象の未完了サブタスクはありません。subtasks/ 配下の各 result.md を読んで統合し、回答を complete してください。SpawnSubTask を再度呼ぶ必要はありません。', + isError: false, + }; + } + // 制御フロー(park シグナル): agent-loop がこのフラグを見て movement を + // WAIT_SUBTASKS で停車させ、同じ movement に再開する。ツール内で同期ブロックは + // しない(ワーカー解放を保つ=デッドロック回避)。 + ctx.pendingSubtaskWait = true; + return { + output: 'サブタスクの完了を待機します。全完了後、同じステップに再開し、subtasks/ の結果を読めます。', + isError: false, + }; + } if (name === 'delegate') { if (!ctx.runDelegate) { diff --git a/src/engine/tools/tool-categories.test.ts b/src/engine/tools/tool-categories.test.ts index b087bb7..ba6c1fd 100644 --- a/src/engine/tools/tool-categories.test.ts +++ b/src/engine/tools/tool-categories.test.ts @@ -70,6 +70,14 @@ describe('tool-categories', () => { expect(SENSITIVE_TOOLS.has('Read')).toBe(false); }); + it('marks SpawnSubTask as a sensitive (opt-in) tool but not WaitSubTask', () => { + // D1: SpawnSubTask is opt-in (parallel separate-job decomposition). delegate is + // the default-on serial path. WaitSubTask stays default-on (no-op without children). + expect(SENSITIVE_TOOLS.has('SpawnSubTask')).toBe(true); + expect(SENSITIVE_TOOLS.has('WaitSubTask')).toBe(false); + expect(SENSITIVE_TOOLS.has('delegate')).toBe(false); + }); + it('exports META_TOOLS with expected entries', () => { expect(META_TOOLS).toBeInstanceOf(Set); expect(META_TOOLS.has('ReadToolDoc')).toBe(true); diff --git a/src/engine/tools/tool-categories.ts b/src/engine/tools/tool-categories.ts index b941a8d..e96c051 100644 --- a/src/engine/tools/tool-categories.ts +++ b/src/engine/tools/tool-categories.ts @@ -56,8 +56,17 @@ export const SENSITIVE_CATEGORIES: ReadonlySet = new Set(['ssh', 'browse /** * Individual tools that require elevated scrutiny regardless of their * category (e.g. Bash even though its category is 'core'). + * + * SpawnSubTask: parallel separate-job decomposition. Each spawned child takes a + * worker + its own workspace + a GPU slot, so a burst can starve other queued + * work. Built-in pieces now decompose via the in-process serial `delegate` tool + * (orchestration category, default-on), so SpawnSubTask is opt-in only — a + * workspace must explicitly enable it when it genuinely needs parallel subtasks. + * The code (SpawnSubTask / WAIT_SUBTASKS / WaitSubTask) stays; only the default + * exposure flips. WaitSubTask stays in the default-on 'orchestration' category: + * it's a no-op when there are no children, so it's harmless without SpawnSubTask. */ -export const SENSITIVE_TOOLS: ReadonlySet = new Set(['Bash']); +export const SENSITIVE_TOOLS: ReadonlySet = new Set(['Bash', 'SpawnSubTask']); // ───────────────────────────────────────────────────────────────────────────── // Module specs — canonical category → module specifier mapping diff --git a/src/engine/tools/web.test.ts b/src/engine/tools/web.test.ts index 89167af..daa00f8 100644 --- a/src/engine/tools/web.test.ts +++ b/src/engine/tools/web.test.ts @@ -53,6 +53,14 @@ describe('sanitizeQuery', () => { it('preserves public IPs', () => { expect(sanitizeQuery('query 8.8.8.8 dns', {})).toBe('query 8.8.8.8 dns'); }); + it('returns null for missing / non-string query instead of throwing', () => { + // 退行したモデルが query なしの WebSearch を吐くと undefined が渡ってくる + expect(sanitizeQuery(undefined as unknown as string, {})).toBeNull(); + expect(sanitizeQuery(null as unknown as string, {})).toBeNull(); + expect(sanitizeQuery(123 as unknown as string, {})).toBeNull(); + expect(sanitizeQuery('', {})).toBeNull(); + expect(sanitizeQuery(' ', {})).toBeNull(); + }); }); describe('web tools', () => { @@ -148,6 +156,17 @@ describe('web tools', () => { expect(fs.existsSync(path.join(workspacePath, 'logs', 'webfetch-screenshots'))).toBe(false); }); + it('returns a friendly error (not a crash) when WebSearch query is missing', async () => { + workspacePath = makeWorkspace(); + + // 引数欠落のツール呼び出し(退行モデル由来)でジョブ全体を巻き添えにしない + const result = await executeTool('WebSearch', {}, makeContext(workspacePath)); + + expect(result).not.toBeNull(); + expect(result?.isError).toBe(true); + expect(result?.output).toContain('query'); + }); + it('records invalid URL attempts before fetch', async () => { workspacePath = makeWorkspace(); diff --git a/src/engine/tools/web.ts b/src/engine/tools/web.ts index 9825d05..f2bad98 100644 --- a/src/engine/tools/web.ts +++ b/src/engine/tools/web.ts @@ -108,7 +108,16 @@ const INTERNAL_DOMAIN_PATTERN = /\b[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.(?: * 検索クエリから機密情報を除去する。 * @returns サニタイズ済みクエリ。空になった場合は null を返す。 */ -export function sanitizeQuery(query: string, config: SearchFilterConfig): string | null { +export function sanitizeQuery( + query: string | null | undefined, + config: SearchFilterConfig, +): string | null { + // 退行したモデルが query 引数なし(または非文字列)の WebSearch を吐くことがある。 + // ここで弾かないと undefined.replace() でジョブ全体がクラッシュする。 + if (typeof query !== 'string' || query.trim().length === 0) { + return null; + } + const autoBlock = { privateIp: config.autoBlock?.privateIp !== false, internalDomain: config.autoBlock?.internalDomain !== false, @@ -630,10 +639,15 @@ async function executeWebSearch( input: Record, ctx: ToolContext, ): Promise { - const rawQuery = input['query'] as string; + const rawQuery = input['query']; const rawLimit = typeof input['limit'] === 'number' ? input['limit'] : 5; const limit = Math.min(Math.max(1, rawLimit), 20); + // query 引数の欠落(退行モデルの不正なツール呼び出し)はクラッシュさせず親切に返す + if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) { + return { output: 'WebSearch には query(検索文字列)を指定してください。', isError: true }; + } + // 検索クエリの機密情報フィルタリング const filterConfig = ctx.searchFilter ?? ctx.toolsConfig?.searchFilter ?? {}; const sanitized = sanitizeQuery(rawQuery, filterConfig); diff --git a/src/engine/workspace-ssh-connections.test.ts b/src/engine/workspace-ssh-connections.test.ts new file mode 100644 index 0000000..f9d8e29 --- /dev/null +++ b/src/engine/workspace-ssh-connections.test.ts @@ -0,0 +1,275 @@ +/** + * Tests for workspace-scoped SSH connection resolution (fix/workspace-ssh-connections). + * + * Two concerns: + * 1. Resolution contract: getSshSubsystem().connectionRepo.listBySpace(spaceId) returns + * only the IDs registered to that space, never another space's connections. + * 2. piece-runner threading: workspaceSshConnections option overrides + * movement.allowed_ssh_connections in ctx, and falls back to movement when absent. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +import { runMigrations } from '../db/migrate.js'; +import { createConnectionRepo, type CreateConnectionInput } from '../ssh/connection-repo.js'; +import type { PieceDef } from './piece-runner.js'; +import type { MovementResult } from './agent-loop.js'; + +// ── Mock agent-loop so runPiece short-circuits without an LLM ────────────── +vi.mock('./agent-loop.js', () => ({ + executeMovement: vi.fn(), +})); + +import { executeMovement } from './agent-loop.js'; +import { runPiece } from './piece-runner.js'; + +const executeMovementMock = vi.mocked(executeMovement); + +// ── DB helpers ──────────────────────────────────────────────────────────── + +const VALID_KEY = 'a'.repeat(64); + +function bootstrapDb(): Database.Database { + process.env.MCP_ENCRYPTION_KEY = VALID_KEY; + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`); + db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`); + db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); + runMigrations(db); + db.prepare(`INSERT INTO users(id) VALUES (?), (?)`).run('alice', 'bob'); + return db; +} + +function baseInput(overrides: Partial = {}): CreateConnectionInput { + return { + ownerId: 'alice', + label: 'prod', + host: 'srv.example.com', + port: 22, + username: 'deploy', + privateKeyEnc: Buffer.from([1, 2, 3]), + keyFingerprint: 'SHA256:abc', + remotePathPrefix: '/home/deploy', + spaceId: null, + ...overrides, + }; +} + +// ── Workspace helpers ────────────────────────────────────────────────────── + +function makeGitWorkspace(): string { + const dir = mkdtempSync(join(tmpdir(), 'test-ws-ssh-')); + execFileSync('git', ['init', dir]); + execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']); + execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']); + return dir; +} + +// ── Minimal piece for runPiece invocation ────────────────────────────────── + +function makePiece(allowedSsh?: string[]): PieceDef { + return { + name: 'test-piece', + description: 'test', + max_movements: 5, + initial_movement: 'execute', + movements: [ + { + name: 'execute', + edit: false, + persona: 'worker', + instruction: 'do it', + allowed_tools: ['Read'], + allowed_ssh_connections: allowedSsh, + rules: [], + default_next: 'COMPLETE', + }, + ], + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. listBySpace cross-space isolation +// ───────────────────────────────────────────────────────────────────────────── + +describe('SshConnectionRepo.listBySpace — cross-space isolation', () => { + let db: Database.Database; + + beforeEach(() => { + db = bootstrapDb(); + }); + + afterEach(() => { + db.close(); + delete process.env.MCP_ENCRYPTION_KEY; + }); + + it('returns only connections registered to the queried space', () => { + const repo = createConnectionRepo(db); + + const a1 = repo.create(baseInput({ spaceId: 'space-A', label: 'a1' })); + const a2 = repo.create(baseInput({ spaceId: 'space-A', label: 'a2' })); + const b1 = repo.create(baseInput({ spaceId: 'space-B', label: 'b1' })); + // space-less connection (should NEVER appear in space-A results) + repo.create(baseInput({ spaceId: null, label: 'legacy' })); + + const ids = repo.listBySpace('space-A').map((c) => c.id); + + expect(ids).toHaveLength(2); + expect(ids).toContain(a1.id); + expect(ids).toContain(a2.id); + // Cross-space isolation: space-B connection must NOT be visible to space-A + expect(ids).not.toContain(b1.id); + }); + + it('returns an empty array when no connections are registered to a space', () => { + const repo = createConnectionRepo(db); + // Register a connection in a different space only + repo.create(baseInput({ spaceId: 'space-other', label: 'other' })); + + expect(repo.listBySpace('space-empty')).toEqual([]); + }); + + it('space-less (null space_id) connections are excluded from listBySpace', () => { + const repo = createConnectionRepo(db); + // Only a space-less connection exists + repo.create(baseInput({ spaceId: null, label: 'legacy' })); + + // No connection registered to space-X — listBySpace must return [] + expect(repo.listBySpace('space-X')).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. piece-runner: workspaceSshConnections option threading +// ───────────────────────────────────────────────────────────────────────────── + +describe('piece-runner workspaceSshConnections threading', () => { + beforeEach(() => { + executeMovementMock.mockReset(); + // By default, executeMovement resolves as if the movement completed. + executeMovementMock.mockResolvedValue({ + status: 'aborted', + abortReason: 'agent_self_abort', + output: '', + } as MovementResult); + }); + + it('ctx.allowedSshConnections reflects workspaceSshConnections when provided', async () => { + const ws = makeGitWorkspace(); + const wsConns = ['conn-aaa-111', 'conn-bbb-222']; + const piece = makePiece(undefined); // no movement declaration + + try { + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, { + workspaceSshConnections: wsConns, + }); + + expect(executeMovementMock).toHaveBeenCalled(); + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + // Workspace-scoped list governs + expect(ctx.allowedSshConnections).toEqual(wsConns); + } finally { + rmSync(ws, { recursive: true }); + } + }); + + it('workspaceSshConnections overrides movement.allowed_ssh_connections', async () => { + const ws = makeGitWorkspace(); + const movementConns = ['conn-from-movement-decl']; + const wsConns = ['conn-from-workspace']; + const piece = makePiece(movementConns); // movement has its own declaration + + try { + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, { + workspaceSshConnections: wsConns, + }); + + expect(executeMovementMock).toHaveBeenCalled(); + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + // Workspace wins; movement declaration ignored + expect(ctx.allowedSshConnections).toEqual(wsConns); + expect(ctx.allowedSshConnections).not.toContain('conn-from-movement-decl'); + } finally { + rmSync(ws, { recursive: true }); + } + }); + + // Security: a movement declaring the legacy wildcard ['*'] must be fully + // suppressed when the workspace supplies a scoped connection list. Proves the + // "NEVER a global wildcard under the workspace model" guarantee is testable. + it('workspace list suppresses a movement-declared ["*"] wildcard', async () => { + const ws = makeGitWorkspace(); + const wsConns = ['conn-from-workspace']; + const piece = makePiece(['*']); // movement declares the all-connections wildcard + + try { + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, { + workspaceSshConnections: wsConns, + }); + + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + expect(ctx.allowedSshConnections).toEqual(wsConns); // scoped list, NOT ['*'] + expect(ctx.allowedSshConnections).not.toContain('*'); // wildcard fully suppressed + } finally { + rmSync(ws, { recursive: true }); + } + }); + + it('empty workspaceSshConnections ([]) is respected (ssh enabled, no registered connections)', async () => { + const ws = makeGitWorkspace(); + const piece = makePiece(['conn-from-movement-decl']); // movement declaration present + + try { + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, { + workspaceSshConnections: [], // ssh enabled but 0 connections in space + }); + + expect(executeMovementMock).toHaveBeenCalled(); + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + // Empty list propagates — preflight will cleanly reject any connection attempt + expect(ctx.allowedSshConnections).toEqual([]); + } finally { + rmSync(ws, { recursive: true }); + } + }); + + it('falls back to movement.allowed_ssh_connections when workspaceSshConnections is absent', async () => { + const ws = makeGitWorkspace(); + const movementConns = ['conn-from-movement-decl']; + const piece = makePiece(movementConns); + + try { + // No workspaceSshConnections option — legacy / unit-test caller path + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {}); + + expect(executeMovementMock).toHaveBeenCalled(); + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + // Falls back to movement declaration + expect(ctx.allowedSshConnections).toEqual(movementConns); + } finally { + rmSync(ws, { recursive: true }); + } + }); + + it('ctx.allowedSshConnections is undefined when both workspaceSshConnections and movement declaration are absent', async () => { + const ws = makeGitWorkspace(); + const piece = makePiece(undefined); // no movement declaration + + try { + // No workspaceSshConnections — ssh not enabled in policy + await runPiece(piece, 'TASK', {} as never, ws, undefined, undefined, {}); + + expect(executeMovementMock).toHaveBeenCalled(); + const ctx = executeMovementMock.mock.calls[0]![3] as { allowedSshConnections?: string[] }; + // undefined → ssh.ts preflight will produce "does not declare allowed_ssh_connections" + expect(ctx.allowedSshConnections).toBeUndefined(); + } finally { + rmSync(ws, { recursive: true }); + } + }); +}); diff --git a/src/engine/workspace-tool-policy.test.ts b/src/engine/workspace-tool-policy.test.ts index a794964..4d86889 100644 --- a/src/engine/workspace-tool-policy.test.ts +++ b/src/engine/workspace-tool-policy.test.ts @@ -12,6 +12,9 @@ describe('workspace tool policy', () => { expect(r.allowedTools).toContain('WebSearch'); expect(r.allowedTools).toContain('ReadToolDoc'); // META always expect(r.allowedTools).not.toContain('Bash'); // sensitive tool + expect(r.allowedTools).not.toContain('SpawnSubTask'); // D1: sensitive (opt-in) tool + expect(r.allowedTools).toContain('delegate'); // D1: serial decomposition is default-on + expect(r.allowedTools).toContain('WaitSubTask'); // default-on (no-op without children) expect(r.allowedTools).not.toContain('BrowseWeb'); // sensitive category expect(r.allowedTools).not.toContain('SshExec'); expect(r.editAllowed).toBe(true); // Write/Edit are safe-on @@ -22,6 +25,13 @@ describe('workspace tool policy', () => { expect(r.allowedTools).toContain('BrowseWeb'); expect(r.allowedTools).not.toContain('SshExec'); // ssh not opted in }); + it('enabledSensitive turns on SpawnSubTask only when listed (D1)', async () => { + expect((await resolveWorkspaceTools({})).allowedTools).not.toContain('SpawnSubTask'); + const r = await resolveWorkspaceTools({ enabledSensitive: ['SpawnSubTask'] }); + expect(r.allowedTools).toContain('SpawnSubTask'); + expect(r.allowedTools).toContain('delegate'); // delegate stays available + expect(r.allowedTools).toContain('WaitSubTask'); // pairs cleanly when opted in + }); it('disabledSafe removes a safe category', async () => { const r = await resolveWorkspaceTools({ disabledSafe: ['web'] }); expect(r.allowedTools).not.toContain('WebSearch'); diff --git a/src/metrics/tool-name-allowlist.ts b/src/metrics/tool-name-allowlist.ts index adf3d6d..76303ea 100644 --- a/src/metrics/tool-name-allowlist.ts +++ b/src/metrics/tool-name-allowlist.ts @@ -52,7 +52,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray = [ // browser.ts 'BrowseWeb', 'BrowseWithSession', 'InteractiveBrowse', // orchestration.ts - 'SpawnSubTask', + 'SpawnSubTask', 'WaitSubTask', // x.ts 'XFetchCardMedia', 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts', // maps.ts diff --git a/src/services/space-credential-transfer.test.ts b/src/services/space-credential-transfer.test.ts new file mode 100644 index 0000000..7d9e911 --- /dev/null +++ b/src/services/space-credential-transfer.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import { createRegistry } from '../mcp/registry.js'; +import { listMcpCandidates, transferMcpServer, listSshCandidates, transferSshConnection, listTransferCandidates, executeTransfer } from './space-credential-transfer.js'; +import { createConnectionRepo } from '../ssh/connection-repo.js'; +import { encryptPrivateKey, decryptPrivateKey, bootstrapSystemDek } from '../ssh/crypto.js'; + +function freshDb(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + // runMigrations needs these base tables to exist (it ALTERs them). + db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`); + db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`); + db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); + runMigrations(db); + return db; +} + +describe('MCP cross-space transfer', () => { + let db: Database.Database; + beforeEach(() => { + process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64); + db = freshDb(); + }); + + it('lists source MCP servers and flags same-name targets as skip', () => { + const reg = createRegistry(db); + reg.upsert({ id: 'a1', name: 'NocoDB', url: 'https://noco.example', authKind: 'api_key', staticToken: 'tok', ownerId: null, spaceId: 'A' }); + reg.upsert({ id: 'b1', name: 'NocoDB', url: 'https://noco.example', authKind: 'api_key', staticToken: 'other', ownerId: null, spaceId: 'B' }); + const cands = listMcpCandidates({ db }, 'A', 'B'); + expect(cands).toHaveLength(1); + expect(cands[0]).toMatchObject({ type: 'mcp', name: 'NocoDB', willSkip: true }); + }); + + it('flags same-url (different name) targets as skip', () => { + const reg = createRegistry(db); + reg.upsert({ id: 'a1', name: 'X', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' }); + reg.upsert({ id: 'b1', name: 'Y', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'B' }); + const cands = listMcpCandidates({ db }, 'A', 'B'); + expect(cands).toHaveLength(1); + expect(cands[0]).toMatchObject({ type: 'mcp', name: 'X', willSkip: true }); + }); + + it('copies an MCP server into the target space, re-encrypting the secret', () => { + const reg = createRegistry(db); + reg.upsert({ id: 'a1', name: 'Linear', url: 'https://linear.example', authKind: 'api_key', staticToken: 'secret-token', ownerId: null, spaceId: 'A' }); + const out = transferMcpServer({ db }, 'a1', 'A', 'B', 'user-x'); + expect(out.copied).toMatchObject({ type: 'mcp', name: 'Linear' }); + const copied = reg.listForSpace('B'); + expect(copied).toHaveLength(1); + const dec = reg.getDecrypted(copied[0].id)!; + expect(dec.staticToken).toBe('secret-token'); + expect(dec.spaceId).toBe('B'); + expect(copied[0].id).not.toBe('a1'); // new id + }); + + it('skips copy when same-url already exists in target', () => { + const reg = createRegistry(db); + reg.upsert({ id: 'a1', name: 'X', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' }); + reg.upsert({ id: 'b1', name: 'Y', url: 'https://dup.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'B' }); + const out = transferMcpServer({ db }, 'a1', 'A', 'B', 'user-x'); + expect(out.skipped).toMatchObject({ type: 'mcp', name: 'X' }); + expect(reg.listForSpace('B')).toHaveLength(1); + }); +}); + +describe('SSH cross-space transfer', () => { + let db: Database.Database; + beforeEach(() => { + process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64); + db = freshDb(); + bootstrapSystemDek(db); + }); + + function seedSsh(spaceId: string, label: string, pem: string): string { + const repo = createConnectionRepo(db); + const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), spaceId); + return repo.create({ + ownerId: null, spaceId, label, host: 'h.example', port: 22, username: 'svc', + privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: 'SHA256:x', + remotePathPrefix: '/srv', allowRemoteUnrestricted: false, allowPrivateAddresses: false, + commandDenyPatterns: null, commandAllowPatterns: null, + }).id; + } + + it('lists SSH candidates and flags same-label targets as skip', () => { + seedSsh('A', 'prod', '-----BEGIN OPENSSH PRIVATE KEY-----\nKEY\n-----END OPENSSH PRIVATE KEY-----'); + seedSsh('B', 'prod', '-----BEGIN OPENSSH PRIVATE KEY-----\nKEY2\n-----END OPENSSH PRIVATE KEY-----'); + const cands = listSshCandidates({ db }, 'A', 'B'); + expect(cands).toHaveLength(1); + expect(cands[0]).toMatchObject({ type: 'ssh', name: 'prod', willSkip: true }); + }); + + it('re-encrypts the private key under the target space DEK (round-trip)', () => { + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEYMATERIAL\n-----END OPENSSH PRIVATE KEY-----'; + const srcId = seedSsh('A', 'prod', pem); + const out = transferSshConnection({ db }, srcId, 'A', 'B', 'user-x'); + expect(out.copied).toMatchObject({ type: 'ssh', name: 'prod' }); + + const repo = createConnectionRepo(db); + const copied = repo.listBySpace('B'); + expect(copied).toHaveLength(1); + // 移し先(B)のDEKで復号して平文一致 + const dec = decryptPrivateKey(db, null, copied[0].privateKeyEnc, 'B'); + expect(dec.toString()).toBe(pem); + dec.fill(0); + // 元(A)のDEKでは復号できない(束縛確認) + expect(() => decryptPrivateKey(db, null, copied[0].privateKeyEnc, 'A')).toThrow(); + }); + + it('skips when target already has same label', () => { + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nK\n-----END OPENSSH PRIVATE KEY-----'; + const srcId = seedSsh('A', 'dup', pem); + seedSsh('B', 'dup', pem); + const out = transferSshConnection({ db }, srcId, 'A', 'B', 'user-x'); + expect(out.skipped).toMatchObject({ type: 'ssh', name: 'dup' }); + expect(createConnectionRepo(db).listBySpace('B')).toHaveLength(1); + }); +}); + +describe('orchestration', () => { + beforeEach(() => { process.env.MCP_ENCRYPTION_KEY = '0'.repeat(64); }); + + it('executes a mixed transfer and returns copied/skipped summary', () => { + const db = freshDb(); + bootstrapSystemDek(db); + const reg = createRegistry(db); + reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' }); + const repo = createConnectionRepo(db); + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEY\n-----END OPENSSH PRIVATE KEY-----'; + const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'A'); + const s1 = repo.create({ + ownerId: null, spaceId: 'A', label: 'box', host: 'h', port: 22, username: 'u', + privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null, + remotePathPrefix: '/x', allowRemoteUnrestricted: false, allowPrivateAddresses: false, + commandDenyPatterns: null, commandAllowPatterns: null, + }).id; + + const result = executeTransfer({ db }, { sourceSpaceId: 'A', targetSpaceId: 'B', actorId: 'u1', mcpServerIds: ['m1'], sshConnectionIds: [s1] }); + expect(result.copied).toHaveLength(2); + expect(result.skipped).toHaveLength(0); + expect(reg.listForSpace('B')).toHaveLength(1); + expect(repo.listBySpace('B')).toHaveLength(1); + }); + + it('listTransferCandidates returns combined mcp+ssh', () => { + const db = freshDb(); + bootstrapSystemDek(db); + const reg = createRegistry(db); + reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'api_key', staticToken: 't', ownerId: null, spaceId: 'A' }); + const repo = createConnectionRepo(db); + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nFAKEKEY\n-----END OPENSSH PRIVATE KEY-----'; + const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'A'); + repo.create({ + ownerId: null, spaceId: 'A', label: 'box', host: 'h', port: 22, username: 'u', + privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null, + remotePathPrefix: '/x', allowRemoteUnrestricted: false, allowPrivateAddresses: false, + commandDenyPatterns: null, commandAllowPatterns: null, + }); + + const cands = listTransferCandidates({ db }, 'A', 'B'); + expect(cands.mcp).toHaveLength(1); + expect(cands.mcp[0]).toMatchObject({ type: 'mcp', name: 'Linear', willSkip: false }); + expect(cands.ssh).toHaveLength(1); + expect(cands.ssh[0]).toMatchObject({ type: 'ssh', name: 'box', willSkip: false }); + }); + + it('IDOR: foreign space IDs are skipped, nothing copied into target (Fix 1)', () => { + // Space C owns both resources. Transfer request claims sourceSpaceId=A but uses C's IDs. + const db = freshDb(); + bootstrapSystemDek(db); + const reg = createRegistry(db); + reg.upsert({ id: 'c-mcp-1', name: 'CServer', url: 'https://c.example', authKind: 'api_key', staticToken: 'c-secret', ownerId: null, spaceId: 'C' }); + const repo = createConnectionRepo(db); + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nCKEY\n-----END OPENSSH PRIVATE KEY-----'; + const { blob, keyVersion } = encryptPrivateKey(db, null, Buffer.from(pem), 'C'); + const cSshId = repo.create({ + ownerId: null, spaceId: 'C', label: 'c-box', host: 'c.h', port: 22, username: 'cu', + privateKeyEnc: blob, passphraseEnc: null, keyVersion, keyFingerprint: null, + remotePathPrefix: '/c', allowRemoteUnrestricted: false, allowPrivateAddresses: false, + commandDenyPatterns: null, commandAllowPatterns: null, + }).id; + + // Claim source=A but pass C's resource IDs → both should be skipped, space B untouched + const result = executeTransfer({ db }, { + sourceSpaceId: 'A', + targetSpaceId: 'B', + actorId: 'attacker', + mcpServerIds: ['c-mcp-1'], + sshConnectionIds: [cSshId], + }); + + expect(result.copied).toHaveLength(0); + expect(result.skipped).toHaveLength(2); + expect(result.skipped[0].reason).toBe('指定元ワークスペースに属さない'); + expect(result.skipped[1].reason).toBe('指定元ワークスペースに属さない'); + + // Space C's rows must be unchanged + expect(reg.listForSpace('C')).toHaveLength(1); + expect(repo.listBySpace('C')).toHaveLength(1); + // Space B must remain empty + expect(reg.listForSpace('B')).toHaveLength(0); + expect(repo.listBySpace('B')).toHaveLength(0); + }); + + it('missing IDs are skipped without throwing (Fix 2)', () => { + const db = freshDb(); + bootstrapSystemDek(db); + const result = executeTransfer({ db }, { + sourceSpaceId: 'A', + targetSpaceId: 'B', + actorId: 'u1', + mcpServerIds: ['does-not-exist-mcp'], + sshConnectionIds: ['does-not-exist-ssh'], + }); + expect(result.copied).toHaveLength(0); + expect(result.skipped).toHaveLength(2); + expect(result.skipped[0].reason).toBe('見つかりません'); + expect(result.skipped[1].reason).toBe('見つかりません'); + }); + + it('does NOT copy user_mcp_tokens rows', () => { + const db = freshDb(); + const reg = createRegistry(db); + reg.upsert({ id: 'm1', name: 'Linear', url: 'https://l.example', authKind: 'oauth', oauthClientId: 'cid', oauthClientSecret: 'sec', ownerId: null, spaceId: 'A' }); + // ユーザートークンを 1 件入れておく(実スキーマ: created_at 列なし、connected_at/updated_at は DEFAULT) + db.prepare("INSERT INTO user_mcp_tokens (user_id, server_id, access_token_enc) VALUES ('u1','m1', x'00')").run(); + executeTransfer({ db }, { sourceSpaceId: 'A', targetSpaceId: 'B', actorId: 'u1', mcpServerIds: ['m1'], sshConnectionIds: [] }); + const n = db.prepare('SELECT COUNT(*) AS n FROM user_mcp_tokens').get() as { n: number }; + expect(n.n).toBe(1); // 増えていない + }); +}); diff --git a/src/services/space-credential-transfer.ts b/src/services/space-credential-transfer.ts new file mode 100644 index 0000000..e249077 --- /dev/null +++ b/src/services/space-credential-transfer.ts @@ -0,0 +1,222 @@ +import type Database from 'better-sqlite3'; +import { createRegistry } from '../mcp/registry.js'; +import type { McpServerPublic } from '../mcp/types.js'; +import { logger } from '../logger.js'; +import { createConnectionRepo, type SshConnection } from '../ssh/connection-repo.js'; +import { decryptPrivateKey, encryptPrivateKey } from '../ssh/crypto.js'; + +export interface TransferDeps { + db: Database.Database; +} + +export interface TransferCandidate { + type: 'mcp' | 'ssh'; + id: string; + name: string; + detail: string; + willSkip: boolean; + skipReason?: string; +} + +export interface TransferResult { + copied: Array<{ type: 'mcp' | 'ssh'; id: string; name: string }>; + skipped: Array<{ type: 'mcp' | 'ssh'; name: string; reason: string }>; +} + +/** 移し先に同名 or 同 url の MCP があるか。 */ +function mcpSkipReason(target: McpServerPublic[], src: McpServerPublic): string | null { + if (target.some((t) => t.name === src.name)) return `同名のサーバが既にあります(${src.name})`; + if (target.some((t) => t.url === src.url)) return `同じ接続先が既にあります(${src.url})`; + return null; +} + +export function listMcpCandidates( + deps: TransferDeps, + sourceSpaceId: string, + targetSpaceId: string, +): TransferCandidate[] { + const reg = createRegistry(deps.db); + const target = reg.listForSpace(targetSpaceId); + return reg.listForSpace(sourceSpaceId).map((s) => { + const reason = mcpSkipReason(target, s); + return { + type: 'mcp' as const, + id: s.id, + name: s.name, + detail: s.url, + willSkip: reason !== null, + skipReason: reason ?? undefined, + }; + }); +} + +export function transferMcpServer( + deps: TransferDeps, + sourceId: string, + sourceSpaceId: string, + targetSpaceId: string, + actorId: string, +): { copied?: { type: 'mcp'; id: string; name: string }; skipped?: { type: 'mcp'; name: string; reason: string } } { + const reg = createRegistry(deps.db); + // 元WSへの所属を「復号せずに」確認する(SSH 経路と対称)。listForSpace は + // McpServerPublic(シークレット非返却)なので、他スペースの id では相手の + // シークレットをメモリに展開しないまま skip できる(IDOR 対策の前段)。 + if (!reg.listForSpace(sourceSpaceId).some((s) => s.id === sourceId)) { + // listPublic も McpServerPublic(シークレット非返却)。存在しない id か、 + // 他スペースの id かを復号せずに区別して正確な理由を返す(SSH 経路と対称)。 + const existsElsewhere = reg.listPublic().some((s) => s.id === sourceId); + return { + skipped: { + type: 'mcp', + name: sourceId, + reason: existsElsewhere ? '指定元ワークスペースに属さない' : '見つかりません', + }, + }; + } + const src = reg.getDecrypted(sourceId); + if (!src) return { skipped: { type: 'mcp', name: sourceId, reason: '見つかりません' } }; + const reason = mcpSkipReason(reg.listForSpace(targetSpaceId), src); + if (reason) return { skipped: { type: 'mcp', name: src.name, reason } }; + + const newId = `${src.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}`; + reg.upsert({ + id: newId, + name: src.name, + url: src.url, + authKind: src.authKind, + ownerId: null, // スペース所有(owner 束縛しない) + spaceId: targetSpaceId, + oauthClientId: src.oauthClientId, + oauthClientSecret: src.oauthClientSecret, + oauthScopes: src.oauthScopes, + staticToken: src.staticToken ?? undefined, + authHeaderName: src.authHeaderName, + enabled: src.enabled, + createdBy: actorId, + }); + logger.info(`[cred-transfer] mcp copied src=${sourceId} -> space=${targetSpaceId} id=${newId}`); + return { copied: { type: 'mcp', id: newId, name: src.name } }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// SSH 経路(DEK ラウンドトリップ) +// ──────────────────────────────────────────────────────────────────────────── + +/** 移し先に同名 or 同接続先の SSH 接続があるか。 */ +function sshSkipReason(target: SshConnection[], src: SshConnection): string | null { + if (target.some((t) => t.label === src.label)) return `同名の接続が既にあります(${src.label})`; + if (target.some((t) => t.host === src.host && t.username === src.username && t.port === src.port)) + return `同じ接続先が既にあります(${src.username}@${src.host}:${src.port})`; + return null; +} + +export function listSshCandidates( + deps: TransferDeps, + sourceSpaceId: string, + targetSpaceId: string, +): TransferCandidate[] { + const repo = createConnectionRepo(deps.db); + const target = repo.listBySpace(targetSpaceId); + return repo.listBySpace(sourceSpaceId).map((s) => { + const reason = sshSkipReason(target, s); + return { + type: 'ssh' as const, + id: s.id, + name: s.label, + detail: `${s.username}@${s.host}:${s.port}`, + willSkip: reason !== null, + skipReason: reason ?? undefined, + }; + }); +} + +export function transferSshConnection( + deps: TransferDeps, + sourceId: string, + sourceSpaceId: string, + targetSpaceId: string, + actorId: string, +): { copied?: { type: 'ssh'; id: string; name: string }; skipped?: { type: 'ssh'; name: string; reason: string } } { + const repo = createConnectionRepo(deps.db); + const src = repo.resolveConnection(sourceId); + if (!src) return { skipped: { type: 'ssh', name: sourceId, reason: '見つかりません' } }; + if (src.spaceId !== sourceSpaceId) { + return { skipped: { type: 'ssh', name: src.label, reason: '指定元ワークスペースに属さない' } }; + } + const reason = sshSkipReason(repo.listBySpace(targetSpaceId), src); + if (reason) return { skipped: { type: 'ssh', name: src.label, reason } }; + + // 元WSのDEKで復号 → 移し先WSのDEKで再暗号化。平文は即クリア。 + const pem = decryptPrivateKey(deps.db, src.ownerId, src.privateKeyEnc, src.spaceId); + let passphrase: Buffer | null = null; + try { + if (src.passphraseEnc) passphrase = decryptPrivateKey(deps.db, src.ownerId, src.passphraseEnc, src.spaceId); + const pk = encryptPrivateKey(deps.db, null, pem, targetSpaceId); + const pp = passphrase ? encryptPrivateKey(deps.db, null, passphrase, targetSpaceId) : null; + const created = repo.create({ + ownerId: null, // スペース所有 + spaceId: targetSpaceId, + label: src.label, + host: src.host, + port: src.port, + username: src.username, + privateKeyEnc: pk.blob, + passphraseEnc: pp ? pp.blob : null, + keyVersion: pk.keyVersion, + keyFingerprint: src.keyFingerprint ?? 'SHA256:unknown', + remotePathPrefix: src.remotePathPrefix, + allowRemoteUnrestricted: src.allowRemoteUnrestricted, + allowPrivateAddresses: src.allowPrivateAddresses, + commandDenyPatterns: src.commandDenyPatterns, + commandAllowPatterns: src.commandAllowPatterns, + }); + logger.info(`[cred-transfer] ssh copied src=${sourceId} -> space=${targetSpaceId} id=${created.id}`); + return { copied: { type: 'ssh', id: created.id, name: src.label } }; + } finally { + pem.fill(0); + passphrase?.fill(0); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// オーケストレーション(MCP + SSH 統合) +// ──────────────────────────────────────────────────────────────────────────── + +/** スペース間転送の候補一覧を MCP・SSH それぞれ返す。 */ +export function listTransferCandidates( + deps: TransferDeps, + sourceSpaceId: string, + targetSpaceId: string, +): { mcp: TransferCandidate[]; ssh: TransferCandidate[] } { + return { + mcp: listMcpCandidates(deps, sourceSpaceId, targetSpaceId), + ssh: listSshCandidates(deps, sourceSpaceId, targetSpaceId), + }; +} + +/** 指定した MCP サーバー・SSH 接続を一括転送し、copied/skipped のサマリを返す。 */ +export function executeTransfer( + deps: TransferDeps, + opts: { + sourceSpaceId: string; + targetSpaceId: string; + actorId: string; + mcpServerIds: string[]; + sshConnectionIds: string[]; + }, +): TransferResult { + return deps.db.transaction(() => { + const result: TransferResult = { copied: [], skipped: [] }; + for (const id of opts.mcpServerIds) { + const r = transferMcpServer(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId); + if (r.copied) result.copied.push(r.copied); + if (r.skipped) result.skipped.push(r.skipped); + } + for (const id of opts.sshConnectionIds) { + const r = transferSshConnection(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId); + if (r.copied) result.copied.push(r.copied); + if (r.skipped) result.skipped.push(r.skipped); + } + return result; + })(); +} diff --git a/src/worker.inherit-job-context.test.ts b/src/worker.inherit-job-context.test.ts new file mode 100644 index 0000000..d4765fc --- /dev/null +++ b/src/worker.inherit-job-context.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Repository } from './db/repository.js'; +import { BrowserSessionRepo } from './db/browser-session-repo.js'; +import { inheritJobContext } from './worker.js'; + +// Regression: a job that DESCENDS from another (subtask spawn, subtask ASK +// re-enqueue) must inherit the parent's bound browser session profile. Without +// it, BrowseWeb inside the derived job runs without the saved login session +// (cookies/storageState are only loaded when the job row carries the profile +// id). research-via-delegate made nearly all browsing happen inside subtasks, +// so the missing inheritance meant "BrowseWeb stopped using the saved session". +describe('inheritJobContext (derived-job session inheritance)', () => { + let dir: string; + let repo: Repository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'inherit-jobctx-')); + repo = new Repository(join(dir, 'db.sqlite')); + // browser_session_profiles.owner_id REFERENCES users(id) (FK ON), so the + // owner must exist before a profile row can be inserted. + repo.getDb().prepare( + `INSERT INTO users (id, email, role, status, created_at, updated_at) + VALUES ('u-1', 'u1@example.com', 'user', 'active', 0, 0)`, + ).run(); + }); + afterEach(() => { + repo.close?.(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('carries the parent job browserSessionProfileId', async () => { + const sess = new BrowserSessionRepo(repo.getDb()); + const profileId = sess.createProfile({ + ownerId: 'u-1', label: 'github', startUrl: 'https://github.com', + matchPatterns: ['github.com'], storageOrigins: ['https://github.com'], + loginUrlPatterns: ['https://github.com/login'], + }); + const parent = await repo.createJob({ + repo: 'local/task-1', issueNumber: 1, instruction: 'x', pieceName: 'general', + ownerId: 'u-1', browserSessionProfileId: profileId, + } as never); + + expect(inheritJobContext(parent).browserSessionProfileId).toBe(profileId); + }); + + it('returns null when the parent has no bound profile', async () => { + const parent = await repo.createJob({ + repo: 'local/task-2', issueNumber: 2, instruction: 'x', pieceName: 'general', + ownerId: 'u-1', + } as never); + expect(inheritJobContext(parent).browserSessionProfileId).toBeNull(); + }); + + it('also carries ownership, visibility and space', async () => { + const parent = await repo.createJob({ + repo: 'local/task-3', issueNumber: 3, instruction: 'x', pieceName: 'general', + ownerId: 'u-7', visibility: 'org', visibilityScopeOrgId: 'org-9', spaceId: 'case-42', + } as never); + expect(inheritJobContext(parent)).toEqual({ + ownerId: 'u-7', + visibility: 'org', + visibilityScopeOrgId: 'org-9', + spaceId: 'case-42', + browserSessionProfileId: null, + }); + }); + + // The actual bug: a derived job built by spreading inheritJobContext(parent) + // must PERSIST the profile id on its own row (so the worker decrypts and + // injects the saved session when it later runs). + it('a derived job spreading inheritJobContext persists the bound profile', async () => { + const sess = new BrowserSessionRepo(repo.getDb()); + const profileId = sess.createProfile({ + ownerId: 'u-1', label: 'x', startUrl: 'https://x.com', + matchPatterns: ['x.com'], storageOrigins: ['https://x.com'], + loginUrlPatterns: ['https://x.com/login'], + }); + const parent = await repo.createJob({ + repo: 'local/task-4', issueNumber: 4, instruction: 'parent', pieceName: 'general', + ownerId: 'u-1', browserSessionProfileId: profileId, + } as never); + + // Mirror worker.ts subtask spawn: spread the inherited context. + const sub = await repo.createJob({ + repo: `subtask/${parent.id}`, issueNumber: 0, instruction: 'child', + pieceName: 'general', parentJobId: parent.id, subtaskDepth: 1, + ...inheritJobContext(parent), + } as never); + + const reloaded = await repo.getJob(sub.id); + expect(reloaded?.browserSessionProfileId).toBe(profileId); + }); +}); diff --git a/src/worker.ts b/src/worker.ts index c1187f6..eeac7fa 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -25,6 +25,7 @@ import { pickIdlerIndex } from './worker/idle-routing.js'; import { jobEventBus } from './bridge/job-events.js'; import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js'; import { parseToolPolicy, resolveWorkspaceTools } from './engine/workspace-tool-policy.js'; +import { getSshSubsystem } from './engine/tools/ssh.js'; const RETRY_HANDOFF_MAX_LENGTH = 8_000; const RETRY_DIAGNOSTICS_PREVIEW_LENGTH = 1_200; @@ -168,6 +169,34 @@ async function resolveSessionTaskId( return { taskId: undefined, userId: job.ownerId ?? undefined }; } +/** + * Fields a derived job must inherit from the job it descends from: ownership, + * visibility, space, and — critically — the bound browser session profile. + * + * Used by every `createJob` that produces a child/continuation of an existing + * job (subtask spawn, subtask ASK re-enqueue). Centralised so the inheritance + * contract lives in ONE place: omitting `browserSessionProfileId` here makes + * BrowseWeb inside the derived job run without the parent's saved login session + * (the cookies/storageState are only loaded when the job row carries the + * profile id). Regression fixed 2026-06-26 — research-via-delegate made nearly + * all browsing happen inside subtasks, surfacing the missing inheritance. + */ +export function inheritJobContext(job: Job): { + ownerId: string | null; + visibility: 'private' | 'org' | 'public'; + visibilityScopeOrgId: string | null; + spaceId: string | null; + browserSessionProfileId: number | null; +} { + return { + ownerId: job.ownerId, + visibility: job.visibility, + visibilityScopeOrgId: job.visibilityScopeOrgId, + spaceId: job.spaceId ?? null, + browserSessionProfileId: job.browserSessionProfileId ?? null, + }; +} + function buildUiMetadataBlock(job: Job): string { return [ '---', @@ -360,6 +389,25 @@ function buildRetryHandoffContext(workspacePath: string, job: Job): string { } } +/** DB-level terminal statuses for a subtask job (distinct from piece-result statuses). */ +const SUBTASK_DB_TERMINAL: readonly string[] = ['succeeded', 'failed', 'cancelled']; + +/** + * Cancelable sleep: resolves after `ms`, but bails early (polling every ~100ms) + * when cancelCheck() returns true. Used for the SpawnSubTask stagger so a + * cancelled parent does not sit sleeping. Not a completion-wait. + */ +async function sleepWithCancel(ms: number, cancelCheck?: () => boolean): Promise { + const step = 100; + let waited = 0; + while (waited < ms) { + if (cancelCheck?.()) return; + const chunk = Math.min(step, ms - waited); + await new Promise((resolve) => setTimeout(resolve, chunk)); + waited += chunk; + } +} + export async function maybeEnqueueReflection( repo: Repository, job: Job, @@ -1375,11 +1423,11 @@ export class Worker { subtaskDepth: job.subtaskDepth + 1, maxAttempts: 2, role: job.requiredRole, - ownerId: job.ownerId, - visibility: job.visibility, - visibilityScopeOrgId: job.visibilityScopeOrgId, - // Spaces foundation (spec §5.7): inherit the parent job's space. - spaceId: job.spaceId ?? null, + // 親ジョブから所有権・可視性・スペース・ブラウザセッションのバインドを + // 継承する(spec §5.7 のスペース継承 + 保存セッションの引き継ぎ)。 + // browserSessionProfileId が無いとサブタスク内の BrowseWeb が + // 保存セッション (ログイン Cookie) を使えず未ログインでアクセスする。 + ...inheritJobContext(job), // 計画5 (Opus P1): サブタスクは isolated worktree で実行され共有 // されないため runtime_dir はネストしない(reporter は // subtaskWorkspace/logs にフォールバックし reader と一致する)。 @@ -1387,9 +1435,23 @@ export class Worker { }); await this.repo.updateJob(subJob.id, { worktreePath: subtaskWorkspace }); logger.info(`[worker:${this.workerId}] spawned sub-task #${subtaskIndex} depth=${job.subtaskDepth + 1} job=${subJob.id}`); + // ④ spawn stagger: brief delay so a burst of N SpawnSubTask enqueues + // doesn't all land in the same instant and jump GPU-slot priority over + // other queued work. Throttle only — NOT a completion-wait. Honors + // cancel so a cancelled parent doesn't sit sleeping. + if (this.config.subtasks.spawnStaggerMs > 0) { + await sleepWithCancel(this.config.subtasks.spawnStaggerMs, cancelCheck); + } return { jobId: subJob.id, subtaskIndex, workspacePath: subtaskWorkspace }; } : undefined, + // WaitSubTask / auto-park support: report whether this job has unwaited + // pending children, and cancel them when the parent ends without consuming + // them (orphan prevention). Both use getSubJobs (latest revision per index). + hasPendingSubtasks: async (): Promise => { + const subs = await this.repo.getSubJobs(jobId); + return subs.some((s) => !SUBTASK_DB_TERMINAL.includes(s.status)); + }, }; const callbacks = this.buildPieceCallbacks( @@ -1634,14 +1696,42 @@ export class Worker { // above by resolveToolSpaceId). Fail-closed: any error → safe defaults // (sensitive tools off). Never throws — a policy error must not crash a job. let workspaceTools: { allowedTools: string[]; editAllowed: boolean }; + let parsedPolicy: import('./engine/workspace-tool-policy.js').WorkspaceToolPolicy; try { const policyJson = spaceId != null ? this.repo.getSpaceToolPolicy(spaceId) : null; - workspaceTools = await resolveWorkspaceTools(parseToolPolicy(policyJson)); + parsedPolicy = parseToolPolicy(policyJson); + workspaceTools = await resolveWorkspaceTools(parsedPolicy); } catch (err) { logger.warn(`[worker:${this.workerId}] job ${jobId} workspace tool policy resolution failed, using safe defaults: ${(err as Error).message}`); + parsedPolicy = {}; workspaceTools = await resolveWorkspaceTools({}); } + // SSH workspace scoping: when the workspace policy enables the 'ssh' category, + // resolve the connection IDs available to this job's space. This replaces the + // per-movement allowed_ssh_connections gate with a space-scoped list, mirroring + // how MCP uses listEnabledForSpace. spaceId is already personal-space-resolved + // (NULL→real-id) by resolveToolSpaceId above, so personal-WS jobs find their + // connections via listBySpace(personalSpaceId). NEVER '*' wildcard — always an + // explicit id list so cross-space isolation is guaranteed. + let workspaceSshConnections: string[] | undefined; + if (parsedPolicy.enabledSensitive?.includes('ssh') && spaceId != null) { + try { + const sshSub = getSshSubsystem(); + if (sshSub !== null) { + workspaceSshConnections = sshSub.connectionRepo.listBySpace(spaceId).map((c) => c.id); + logger.debug(`[worker:${this.workerId}] job ${jobId} ssh workspace connections resolved count=${workspaceSshConnections.length} spaceId=${spaceId}`); + } else { + // SSH subsystem not initialised (e.g. test environment without bridge setup). + // Leave undefined so movement declaration is the fallback. + logger.debug(`[worker:${this.workerId}] job ${jobId} ssh enabled in policy but subsystem not initialised — falling back to movement declaration`); + } + } catch (err) { + logger.warn(`[worker:${this.workerId}] job ${jobId} ssh connection resolution failed: ${(err as Error).message}`); + // Leave undefined — movement declaration is the safe fallback. + } + } + const result = await runPiece(piece, enrichedInstruction, llmClient, workspacePath, callbacks, toolsConfig, { ...pieceOptions, cancelCheck, @@ -1688,6 +1778,9 @@ export class Worker { // Workspace tool policy: pre-resolved once per job above (fail-closed). // Drives every movement's effective allowed tools and edit flag. workspaceTools, + // SSH workspace scoping: connection IDs available to this job's space. + // Undefined when ssh not enabled in policy (movement declaration is fallback). + workspaceSshConnections, // Pause for inline approval only when a user is reachable: a local task // that is not itself a subtask (subtasks/headless runs auto-proceed). toolApprovalInteractive: isLocalTask && !isSubTask, @@ -2143,11 +2236,8 @@ export class Worker { subtaskDepth: job.subtaskDepth, maxAttempts: 2, role: job.requiredRole, - ownerId: job.ownerId, - visibility: job.visibility, - visibilityScopeOrgId: job.visibilityScopeOrgId, - // Spaces foundation: preserve the job's space across the ASK re-enqueue. - spaceId: job.spaceId ?? null, + // ASK 再投入でも所有権・可視性・スペース・ブラウザセッションのバインドを維持する。 + ...inheritJobContext(job), }); await this.repo.updateJob(newJob.id, { worktreePath: workspacePath }); await this.repo.addAuditLog(newJob.id, 'job_queued_subtask_ask_answer', 'worker', { originalJobId: jobId, question: result.finalOutput }); @@ -2203,15 +2293,36 @@ export class Worker { status: 'waiting_subtasks', resumeMovement: result.resumeMovement ?? null, }); - await reporter.reportMovementStart('サブタスク待機中...'); + // P1-3 race guard: a child may have reached its terminal state between the + // park decision (in agent-loop/piece-runner) and this status write. The + // per-child requeue fires on child completion but no-ops while the parent + // is not yet 'waiting_subtasks', so without this re-check the parent could + // park forever. requeueParentJobIfAllSubtasksDone only flips us back to + // 'queued' when every child is already terminal. + const requeuedAtPark = await this.repo.requeueParentJobIfAllSubtasksDone(jobId); + if (requeuedAtPark) { + logger.info(`[worker:${this.workerId}] job ${jobId} all sub-tasks already done at park time, re-queued immediately`); + } else { + await reporter.reportMovementStart('サブタスク待機中...'); + } await this.repo.addAuditLog(jobId, 'job_waiting_subtasks', 'worker', { resumeMovement: result.resumeMovement, + requeuedAtPark, }); } } else if (result.status === 'cancelled') { logger.info(`[worker:${this.workerId}] job ${jobId} cancelled`); + // P1-4 orphan prevention: a cancelled parent never resumes to consume its + // children. Cancel any still-running ones so they don't keep burning GPU. + await this.cancelPendingChildren(jobId, 'parent cancelled'); await reporter.reportFinalResult('cancelled', result.finalOutput); } else { + // P1-4 orphan prevention: a terminal-failure parent (aborted/error/failed) + // will not consume its children; on retry it re-runs from scratch and + // re-spawns. Either way the current attempt's pending children are orphans — + // cancel them. (ASK / waiting_human is handled above and intentionally keeps + // children: that parent resumes on the user's answer.) + await this.cancelPendingChildren(jobId, `parent ${result.status}`); const retryDisposition = await this.scheduleRetryOrFail(job, result.finalOutput, workspacePath, result.abortReason ?? null); if (retryDisposition !== 'requeued_unhealthy') { await reporter.reportFinalResult(result.status, result.finalOutput); @@ -2253,6 +2364,34 @@ export class Worker { }); } + /** + * Orphan prevention (P1-4): cancel this job's not-yet-terminal children when the + * parent ends without consuming them (terminal failure / cancel / retry). Best + * effort — failures are logged, never thrown, so they can't mask the real result. + * Scope: DIRECT children only. A running child observes the cancel via its own + * cancelCheck and aborts. A child that is itself parked (waiting_subtasks) is only + * marked 'cancelled' in the DB and never runs again, so ITS grandchildren keep + * running to completion — wasted GPU, but no hang (nothing waits on them). + * Bounded by maxDepth (default 2); recursive reaping is future scope. + */ + private async cancelPendingChildren(jobId: string, reason: string): Promise { + try { + const subs = await this.repo.getSubJobs(jobId); + let cancelled = 0; + for (const sub of subs) { + if (!SUBTASK_DB_TERMINAL.includes(sub.status)) { + await this.repo.updateJob(sub.id, { status: 'cancelled' }); + cancelled += 1; + } + } + if (cancelled > 0) { + logger.info(`[worker:${this.workerId}] cancelled ${cancelled} pending subtask(s) of job ${jobId} (${reason})`); + } + } catch (err) { + logger.warn(`[worker:${this.workerId}] cancelPendingChildren error for job ${jobId}: ${err}`); + } + } + private resolveModel(piece: PieceDef): string | undefined { if (piece.model) { if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) { diff --git a/ui/src/api.ts b/ui/src/api.ts index d85207f..c183558 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1727,6 +1727,13 @@ export interface WorkerStatusRow { online?: boolean; /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */ backends?: WorkerStatusBackendRow[]; + /** + * Occupants of this worker — users whose running jobs use it, each with + * the job kind (`task_kind`: 'agent' = normal, 'reflection' = learning). + * Admin-only — the server only populates this for admins, so non-admin + * clients always receive undefined. + */ + occupants?: Array<{ user: string; kind: string }>; } export async function fetchWorkerStatuses(): Promise { diff --git a/ui/src/components/dashboard/WorkerStatusWidget.test.tsx b/ui/src/components/dashboard/WorkerStatusWidget.test.tsx new file mode 100644 index 0000000..265f862 --- /dev/null +++ b/ui/src/components/dashboard/WorkerStatusWidget.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import '../../test/dom-setup'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { WorkerStatusRow } from '../../api'; + +// react-i18next: pass keys through (we only assert on the dynamic data). +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Pet hooks are irrelevant here — stub to the "no pet" path so the dot renders. +vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: null }) })); +vi.mock('../../hooks/usePetFrameAnalysis', () => ({ usePetFrameAnalysis: () => 0 })); + +const workersMock = vi.fn(); +vi.mock('../../hooks/useWorkerStatus', () => ({ + useWorkerStatus: () => workersMock(), +})); + +import { WorkerStatusWidget } from './WorkerStatusWidget'; + +function row(over: Partial): WorkerStatusRow { + return { id: 'w1', name: 'w1', roles: ['task'], state: 'running', proxy: false, ...over }; +} + +describe('WorkerStatusWidget — occupants (admin)', () => { + beforeEach(() => workersMock.mockReset()); + + it('renders occupant names, with a kind badge only for non-agent jobs', () => { + workersMock.mockReturnValue({ + workers: [row({ occupants: [{ user: 'Alice', kind: 'agent' }, { user: 'Bob', kind: 'reflection' }] })], + isLoading: false, + isError: false, + }); + render(); + const el = screen.getByTestId('worker-users-w1'); + expect(el.textContent).toContain('Alice'); + expect(el.textContent).toContain('Bob'); + // The reflection job surfaces a kind badge; the normal 'agent' job does not. + const badge = screen.getByTestId('worker-kind-w1'); + expect(badge.textContent).toBe('reflection'); + expect(el.textContent).not.toContain('agent'); + }); + + it('renders no occupant line when occupants is absent (non-admin payload)', () => { + workersMock.mockReturnValue({ + workers: [row({ occupants: undefined })], + isLoading: false, + isError: false, + }); + render(); + expect(screen.queryByTestId('worker-users-w1')).toBeNull(); + }); +}); diff --git a/ui/src/components/dashboard/WorkerStatusWidget.tsx b/ui/src/components/dashboard/WorkerStatusWidget.tsx index f55b29830cea76fe3aecbf348df80995d6cfe4a1..01164fa6c1c9263644ebc55480172ec89da3c930 100644 GIT binary patch delta 976 zcmZ`%O=}ZD7`8$cf<2^ZYOA)s4K-hMHz^`Wn?&nP@FqnsYPP%6WZKPa*_~L!vNW3r zqJkeC6!qjyC}@=&tRnR0rRbj+g8Cc0IGb&^A;dilJM%s~A1}|REAF+AZwJQ!3{|bJ zDTG>idtDAL?B(E-(BX_dIyuq0eC)=+20$jAh6SyHiDVGHHV0J;nU)W&VNwEG#uls_ z<|;C004Zu2)-f%MXM{~yF_vHz6HSIiqM>C>kOs?!3B(|h;*nZ1q%)x|TbJfw!8Dax z&i0fh%sH^ADRkyLJO?*$3iBa{-8p)`wRimH36aYVvOqi@Bw?>2v5SJf`_mMvNEBHV zYk9lqJC{1Cq-oo~Ue%nU6JpQ9qiSSPvn^R7d$k19C-KOB#?b zKylhOWRKc`r1$Hs!bufV@``~;GBE>*l#uxmKEwW=9i6mvK`vd$W@81LCKJ9D8Iwf| z2^=d%qNloZc4U&q4B(W|V)f|w#wx0bNP*~)K(pu+dQ5v8u`v`N>+Q?4;`Br8+wV8Z zq#>b{#9|V_-R(d1H|>p`9p2dGjW4{h&D|~Te&Fsq-h9TJP2Lcr&Gv(@+V7dmoSyj2PWci1(JP?0^}^w_`VZ> zzHRWhd&=N9h&>ITo~-E7EUeuVhiOzrtu { ...actual, getLatestReflectionForTask: vi.fn().mockResolvedValue(null), fetchDelegateRuns: vi.fn().mockResolvedValue([]), + putFeedback: vi.fn(), }; }); const mockedReflection = vi.mocked(api.getLatestReflectionForTask); +const mockedPutFeedback = vi.mocked(api.putFeedback); function makeTask(overrides: Partial = {}): LocalTask { return { @@ -93,4 +96,46 @@ describe('OverviewTab', () => { renderWithProviders(); expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument(); }); + + describe('feedback tags (i18n)', () => { + it('localizes a previously-saved tag for display (EN), hiding the stored JA value', async () => { + mockedReflection.mockResolvedValue(null); + renderWithProviders(); + // The stored canonical JA string is shown localized; the raw JA is gone. + expect(screen.getByText('Accurate output')).toBeInTheDocument(); + expect(screen.queryByText('出力の精度が高い')).not.toBeInTheDocument(); + }); + + it('falls back to the raw value for an unknown/legacy stored tag', async () => { + mockedReflection.mockResolvedValue(null); + renderWithProviders(); + expect(screen.getByText('レガシータグ')).toBeInTheDocument(); + }); + + it('persists the canonical JA value (not the localized label) when a tag is picked', async () => { + mockedReflection.mockResolvedValue(null); + mockedPutFeedback.mockResolvedValue(makeTask()); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: /Good/ })); + // The tag picker shows the localized (EN) label... + await user.click(screen.getByRole('button', { name: 'Accurate output' })); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(mockedPutFeedback).toHaveBeenCalledTimes(1)); + // ...but the stored value stays the canonical JA string (backend/reflection contract). + const [, body] = mockedPutFeedback.mock.calls[0]; + expect(body.tags).toEqual(['出力の精度が高い']); + expect(body.rating).toBe('good'); + }); + }); }); diff --git a/ui/src/components/detail/tabs/OverviewTab.tsx b/ui/src/components/detail/tabs/OverviewTab.tsx index 37d73e9..eba0a4d 100644 --- a/ui/src/components/detail/tabs/OverviewTab.tsx +++ b/ui/src/components/detail/tabs/OverviewTab.tsx @@ -9,11 +9,35 @@ import { ContextUsageGauge } from '../ContextUsageGauge'; import { ReflectionBadge } from '../ReflectionBadge'; import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly'; -const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった']; -const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた']; +// Feedback tags. `value` is the canonical string that gets persisted (and fed +// to the reflection LLM prompt) — it stays in Japanese so existing stored rows +// and the backend are untouched. `key` is the i18n key used purely for display, +// so the picker and the read-back chips localize with the UI language. +const GOOD_TAGS = [ + { value: '出力の精度が高い', key: 'feedback.tags.goodAccuracy' }, + { value: 'フォーマットが適切', key: 'feedback.tags.goodFormat' }, + { value: '指示をよく理解していた', key: 'feedback.tags.goodUnderstanding' }, + { value: '速度が適切だった', key: 'feedback.tags.goodSpeed' }, +]; +const BAD_TAGS = [ + { value: '出力の精度が低い', key: 'feedback.tags.badAccuracy' }, + { value: 'フォーマットが不適切', key: 'feedback.tags.badFormat' }, + { value: '指示と違う結果になった', key: 'feedback.tags.badMismatch' }, + { value: '不要な作業をしていた', key: 'feedback.tags.badUnnecessary' }, + { value: '途中で止まった / ASKが多すぎた', key: 'feedback.tags.badStalled' }, +]; +// value -> i18n key, for localizing previously-saved tags on read-back. Unknown +// values (e.g. older/reworded tags) fall back to the stored string verbatim. +const TAG_KEY = new Map([...GOOD_TAGS, ...BAD_TAGS].map(t => [t.value, t.key])); function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) { const { t } = useTranslation('detail'); + // Localize a stored tag value for display; fall back to the raw value if it's + // not one of the known tags (keeps old/custom strings visible). + const tagLabel = (value: string) => { + const key = TAG_KEY.get(value); + return key ? t(key) : value; + }; const qc = useQueryClient(); const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed'; const hasFeedback = !!task.feedbackRating; @@ -49,7 +73,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: {task.feedbackTags && task.feedbackTags.length > 0 && (
{task.feedbackTags.map(tag => ( - {tag} + {tagLabel(tag)} ))}
)} @@ -92,7 +116,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: {task.feedbackTags && task.feedbackTags.length > 0 && (
{task.feedbackTags.map(tag => ( - {tag} + {tagLabel(tag)} ))}
)} @@ -130,15 +154,15 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
{tags.map(tag => ( ))}
diff --git a/ui/src/components/spaces/ImportFromWorkspaceDialog.tsx b/ui/src/components/spaces/ImportFromWorkspaceDialog.tsx new file mode 100644 index 0000000..a1946f9 --- /dev/null +++ b/ui/src/components/spaces/ImportFromWorkspaceDialog.tsx @@ -0,0 +1,349 @@ +import { useState } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useSpaces } from '../../hooks/useSpaces'; + +// ── API types ───────────────────────────────────────────────────────────── + +interface Candidate { + type: 'mcp' | 'ssh'; + id: string; + name: string; + detail: string; + willSkip: boolean; + skipReason?: string; +} + +interface CandidatesResponse { + mcp: Candidate[]; + ssh: Candidate[]; +} + +interface TransferResult { + copied: { type: string; id: string; name: string }[]; + skipped: { type: string; name: string; reason: string }[]; +} + +// ── API helpers ─────────────────────────────────────────────────────────── + +async function fetchCandidates( + targetSpaceId: string, + sourceId: string, +): Promise { + const res = await fetch( + `/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer/candidates?sourceId=${encodeURIComponent(sourceId)}`, + { credentials: 'include' }, + ); + if (res.status === 403) { + const err = new Error('403'); + err.name = 'ForbiddenError'; + throw err; + } + if (!res.ok) throw new Error(`Failed to fetch candidates: ${res.status}`); + return (await res.json()) as CandidatesResponse; +} + +async function postTransfer( + targetSpaceId: string, + body: { sourceId: string; mcpServerIds: string[]; sshConnectionIds: string[] }, +): Promise { + const res = await fetch( + `/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let msg = `HTTP ${res.status}`; + try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch { /* ignore */ } + throw new Error(msg); + } + return (await res.json()) as TransferResult; +} + +// ── Props ───────────────────────────────────────────────────────────────── + +interface ImportFromWorkspaceDialogProps { + targetSpaceId: string; + kind: 'mcp' | 'ssh'; + open: boolean; + onClose(): void; + onImported(result: TransferResult): void; +} + +// ── Component ───────────────────────────────────────────────────────────── + +export function ImportFromWorkspaceDialog({ + targetSpaceId, + kind, + open, + onClose, + onImported, +}: ImportFromWorkspaceDialogProps) { + const { data: allSpaces, isLoading: spacesLoading } = useSpaces(); + const [sourceId, setSourceId] = useState(''); + const [checked, setChecked] = useState>(new Set()); + const [transferError, setTransferError] = useState(null); + + // Exclude the target space from the source options. + const sourceOptions = (allSpaces ?? []).filter(s => s.id !== targetSpaceId && s.status !== 'archived'); + + // Fetch candidates whenever a source is selected. + const { + data: candidates, + isLoading: candidatesLoading, + error: candidatesError, + } = useQuery({ + queryKey: ['transfer-candidates', targetSpaceId, sourceId], + queryFn: () => fetchCandidates(targetSpaceId, sourceId), + enabled: !!sourceId, + staleTime: 15_000, + }); + + const isForbidden = + candidatesError instanceof Error && candidatesError.name === 'ForbiddenError'; + + // The list to display based on kind. + const items: Candidate[] = candidates ? candidates[kind] : []; + + const transferMut = useMutation({ + mutationFn: (ids: string[]) => { + const body = + kind === 'mcp' + ? { sourceId, mcpServerIds: ids, sshConnectionIds: [] } + : { sourceId, mcpServerIds: [], sshConnectionIds: ids }; + return postTransfer(targetSpaceId, body); + }, + onSuccess: (result) => { + onImported(result); + onClose(); + }, + onError: (err) => { + setTransferError(err instanceof Error ? err.message : String(err)); + }, + }); + + const handleToggle = (id: string) => { + setChecked(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleSourceChange = (id: string) => { + setSourceId(id); + setChecked(new Set()); + setTransferError(null); + }; + + const handleImport = () => { + setTransferError(null); + transferMut.mutate(Array.from(checked)); + }; + + // Reset state when dialog opens/closes. + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setSourceId(''); + setChecked(new Set()); + setTransferError(null); + onClose(); + } + }; + + const checkedCount = checked.size; + const canImport = checkedCount > 0 && !transferMut.isPending; + + const kindLabel = kind === 'mcp' ? 'MCP サーバー' : 'SSH 接続'; + + return ( + + + + +
+ {/* Header */} +
+
+ + 他のワークスペースから取り込む + + + {kindLabel}を別のワークスペースからコピーします。 + +
+ + + +
+ + {/* Source workspace selector */} +
+ + {spacesLoading ? ( +
読み込み中…
+ ) : sourceOptions.length === 0 ? ( +
+ 他にワークスペースがありません。 +
+ ) : ( + + )} +
+ + {/* Candidates list */} + {sourceId && ( +
+ {candidatesLoading && ( +
候補を読み込み中…
+ )} + + {isForbidden && ( +
+ このワークスペースを管理する権限がありません。 +
+ )} + + {candidatesError && !isForbidden && ( +
+ 候補の取得に失敗しました: {(candidatesError as Error).message} +
+ )} + + {!candidatesLoading && !candidatesError && items.length === 0 && ( +
+ 取り込める{kindLabel}がありません。 +
+ )} + + {!candidatesLoading && !candidatesError && items.length > 0 && ( +
+
{kindLabel}
+
    + {items.map(item => ( +
  • + handleToggle(item.id)} + disabled={item.willSkip} + className="mt-0.5 h-4 w-4 flex-shrink-0 rounded border-hairline text-accent focus:ring-accent-ring disabled:cursor-not-allowed" + /> + +
  • + ))} +
+ + {/* Select all / deselect */} + {items.some(i => !i.willSkip) && ( +
+ + +
+ )} +
+ )} +
+ )} + + {/* Transfer error */} + {transferError && ( +
+ 取り込みに失敗しました: {transferError} +
+ )} + + {/* Footer buttons */} +
+ + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/spaces/SpaceDetail.tsx b/ui/src/components/spaces/SpaceDetail.tsx index 11c8a55..4ddb8f1 100644 --- a/ui/src/components/spaces/SpaceDetail.tsx +++ b/ui/src/components/spaces/SpaceDetail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces'; @@ -425,21 +425,39 @@ function SpaceChat({ const qc = useQueryClient(); const auth = useAuthState(); const { data: allTasks } = useLocalTaskList(); + const { data: spaces } = useSpaces(); const [showCreate, setShowCreate] = useState(false); const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter; const setScope = (val: TaskScope) => onFilterChange({ scope: val }); const setSearchQuery = (val: string) => onFilterChange({ search: val }); const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val }); const setSortMode = (val: SortMode) => onFilterChange({ sort: val }); - const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace); - // 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ - // 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks - // ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という - // 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の - // onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)。 const userId = auth.mode === 'authenticated' ? auth.user.id : null; - const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId); + // 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。 + const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true; + // case スペースの id 集合。個人バケツ判定(null か case でない id = 誰かの個人 + // スペース)に使う。admin の listSpaces は全 case スペースを返すので集合は完全。 + const caseSpaceIds = useMemo( + () => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)), + [spaces], + ); + + let spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace, caseSpaceIds); + // 可視性ルール: 個人ワークスペースは本人専用。admin だけが他ユーザーの個人コンテンツを + // 「他のメンバー」タブで監視できる。非 admin の個人スペースは自分の所有分に絞り、他人を + // 一切出さない(リスト API が public/org 等を返しても個人 WS には混ぜない)。 + if (isPersonalSpace && !isAdmin && userId != null) { + spaceTasks = spaceTasks.filter(t => t.ownerId === userId); + } + + // 自分/他メンバーの切替。共有ワークスペースのメンバー間、または個人スペースを admin が + // 監視するときに「他の人のタスク」が存在する場合だけ出す。スコープ分割は Tasks ページと + // 同じ filterTasksByScope を再利用する(owner_id null は others 側、という既存規約に合わ + // せる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の onSelectSpace が + // search/status/sort/scope を明示リセットする(remount 依存ではない)。 + const othersAllowed = !isPersonalSpace || isAdmin; + const hasOthersTasks = othersAllowed && userId != null && spaceTasks.some(t => t.ownerId !== userId); const tasks = userId != null && hasOthersTasks ? filterTasksByScope(spaceTasks, scope, userId) : spaceTasks; diff --git a/ui/src/components/spaces/SpaceRail.tsx b/ui/src/components/spaces/SpaceRail.tsx index 93a3801..1d0122a 100644 --- a/ui/src/components/spaces/SpaceRail.tsx +++ b/ui/src/components/spaces/SpaceRail.tsx @@ -95,7 +95,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) { space={s} active={s.id === selectedId} onSelect={onSelect} - runningCount={countRunningTasksForSpace(tasks ?? [], s)} + runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)} mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId} /> ))} diff --git a/ui/src/components/spaces/SpaceToolSettings.tsx b/ui/src/components/spaces/SpaceToolSettings.tsx index d7175e5..5d1a51a 100644 --- a/ui/src/components/spaces/SpaceToolSettings.tsx +++ b/ui/src/components/spaces/SpaceToolSettings.tsx @@ -28,6 +28,7 @@ const SENSITIVE_NOTE_KEYS: Record = { ssh: 'tools.sensitiveNote.ssh', browser: 'tools.sensitiveNote.browser', Bash: 'tools.sensitiveNote.Bash', + SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask', }; function errMsg(e: unknown): string { diff --git a/ui/src/components/userfolder/McpPanel.tsx b/ui/src/components/userfolder/McpPanel.tsx index 1d070f2..0fd4e42 100644 --- a/ui/src/components/userfolder/McpPanel.tsx +++ b/ui/src/components/userfolder/McpPanel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation, Trans } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuthState } from '../../App'; +import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog'; interface ServerPublic { id: string; @@ -296,6 +297,7 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?: const [editingId, setEditingId] = useState(null); const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); const invalidateAll = () => { qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); @@ -423,11 +425,22 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?: return (
-
-

{t('mcp.title')}

-

- {t('mcp.intro')} -

+
+
+

{t('mcp.title')}

+

+ {t('mcp.intro')} +

+
+ {inSpace && ( + + )}
{isLoading &&
{t('mcp.loading')}
} @@ -493,6 +506,21 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?: )}
+ + {inSpace && importDialogOpen && ( + setImportDialogOpen(false)} + onImported={(result) => { + qc.invalidateQueries({ queryKey: ['mcp-user-servers', spaceId ?? null] }); + const n = result.copied.length; + const m = result.skipped.length; + showToast?.(`${n}件コピー、${m}件スキップ`, 'success'); + }} + /> + )}
); } diff --git a/ui/src/components/userfolder/SshConnectionsPanel.tsx b/ui/src/components/userfolder/SshConnectionsPanel.tsx index b4121da..e1c2c34 100644 --- a/ui/src/components/userfolder/SshConnectionsPanel.tsx +++ b/ui/src/components/userfolder/SshConnectionsPanel.tsx @@ -5,6 +5,7 @@ import type { SshConnection, TestResponse } from '../../lib/ssh-types'; import { SshConnectionForm } from './SshConnectionForm'; import { SshHostKeyDialog } from './SshHostKeyDialog'; import { SshPublicKeyDialog } from './SshPublicKeyDialog'; +import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog'; interface ConnectionsResponse { connections: SshConnection[]; @@ -143,6 +144,7 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP }); const [creating, setCreating] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null); const [pubKeyDialog, setPubKeyDialog] = useState<{ @@ -256,14 +258,25 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP {t('ssh.intro')}

- +
+ {spaceId && ( + + )} + +
{isLoading &&
{t('common.loading')}
} @@ -358,6 +371,21 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP onClose={() => setPubKeyDialog(null)} /> )} + + {spaceId && importDialogOpen && ( + setImportDialogOpen(false)} + onImported={(result) => { + qc.invalidateQueries({ queryKey: ['ssh', 'connections', spaceId ?? null] }); + const n = result.copied.length; + const m = result.skipped.length; + showToast?.(`${n}件コピー、${m}件スキップ`, 'success'); + }} + /> + )} ); } diff --git a/ui/src/content/help/00-changelog.md b/ui/src/content/help/00-changelog.md index 243071c..72ddb7d 100644 --- a/ui/src/content/help/00-changelog.md +++ b/ui/src/content/help/00-changelog.md @@ -12,6 +12,58 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に > 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。 +## 2026-06-29 — ワーカー / GPU の「使用中」に種別バッジを追加(管理者のみ) + +ワーカー一覧の「使用中: …」表示に、いま動いているジョブの**種別バッジ**が付きました。学習(reflection)など通常タスク以外で GPU が埋まっているときに、ユーザー名のとなりに `reflection` のようなタグが出ます。通常タスク(agent)にはバッジを付けないので、「reflection で埋まっているだけ」を一目で見分けられます。これも管理者にだけ見えます(→[管理者向け](./19-admin.md))。 + +## 2026-06-29 — 個人ワークスペースの公開範囲を整理(管理者は全ユーザーを監視可能に) + +個人ワークスペースは作成した本人だけのものになりました。一般ユーザーには、共有されていない他人のチャットが個人ワークスペースに混ざって見えることはありません。一方で**管理者(admin)は、個人ワークスペースの「他のメンバー」タブから他ユーザーの個人ワークスペースの内容も確認できる**ようになりました(全ユーザーの監視のため)。これまでは一部のチャットだけが見えたり、逆に見えるべきものが見えなかったりと不整合がありました(→[ワークスペース](./21-workspaces.md))。 + +## 2026-06-29 — ワーカー / GPU に「使用中のユーザー」を表示(管理者のみ) + +サイドパネルの **ワーカー** 一覧で、実行中のワーカー(GPU)に、いまそのワーカーを占有しているユーザー名が表示されるようになりました。「誰のタスクで GPU が埋まっているのか」を管理者がひと目で把握できます。**この表示は管理者にだけ見えます**。一般ユーザーには従来どおり、稼働状況(実行中/待機)と空きスロットだけが見え、ユーザー名は出ません(→[管理者向け](./19-admin.md))。 + +## 2026-06-29 — ワークスペース間で MCP 接続・SSH 接続をコピーできるように + +ワークスペースの **設定 → MCP**(または **設定 → SSH**)に「他のワークスペースから取り込む」ボタンが追加されました。自分が管理している別のワークスペースから、登録済みの MCP サーバーや SSH 接続をまとめてコピーできます。同名・同じ接続先はスキップされるので、既存の設定が上書きされる心配はありません。なお MCP の OAuth 認証(per-user トークン)は移せないため、取り込み後に移し先で再連携が必要です。SSH の秘密鍵は移し先の鍵で再暗号化されてそのまま使えます(→[MCP サーバー連携](./13-mcp.md)・[SSH リモート操作](./14-ssh.md))。 + +## 2026-06-27 — SSH がワークスペース設定で使えるように + +ワークスペースの **設定 → ツール** で `ssh` カテゴリをオンにし、**設定 → SSH** に接続を登録するだけで、そのワークスペースのタスクから SSH ツール(SshExec / SshUpload / SshDownload / SshConsole 系)が使えるようになりました。以前は piece の YAML に `allowed_ssh_connections` を個別に書く必要があり、汎用ピースでは SSH が実質使えませんでした。スコープはワークスペース単位で厳密に分離されており、別ワークスペースに登録した接続には届きません。 + +## 2026-06-26 — リサーチ等のサブタスクでも保存済みブラウザセッションを使うように + +ログイン状態を保存したブラウザセッションをチャットに紐づけても、エージェントが調査をサブタスク(delegate)に委譲したとき、その中の BrowseWeb が保存セッションを引き継がず未ログインのままアクセスしてしまう不具合を修正しました。委譲先のサブタスクや、質問への回答後に再開したジョブでも、親に紐づけたログインセッションがそのまま使われます。リサーチが委譲経由で動くようになって以降、ログインが必要なサイトの取得に影響していた問題です。 + +## 2026-06-26 — フィードバックの評価タグも英語表示に対応 + +タスクの良かった/改善点フィードバックで選ぶ評価タグ(「出力の精度が高い」など)が、これまで日本語固定でした。表示言語が English のときは英語で表示されるようにしました。過去に登録済みのフィードバックも、保存内容はそのままに表示だけ言語に追従します。 + +## 2026-06-26 — 調査タスクの分割を直列 delegate に切り替え + +調査・分析・ブレインストーミング系のタスクで、サブ調査を **1 件ずつ順番に(直列 delegate)** 進めるようになりました。並列で同時に走らせる方式から既定を変更しています。 + +- **品質が揃いやすく、軽い**: 各サブ調査が綺麗なコンテキストで動くので結果のばらつきが減り、GPU・実行枠の消費も抑えられます。並列に比べると壁時計時間は少し長くなります。 +- **並列サブタスク(SpawnSubTask)は任意有効化に**: 別ジョブで同時並列に走らせる SpawnSubTask は、**デフォルト無効**になりました。本当に並列が必要なワークスペースだけ、**設定 → ツール** タブで有効にしてください(Bash などと同じ「センシティブ/任意有効化」の扱い)。ふだんの分割は delegate で完結するため、有効化は不要です。 + +詳しくは「[サブ実行・並列実行](./10-subtasks.md)」を参照してください。 + +## 2026-06-26 — 並列サブタスクの待機と安全策を強化 + +大きな仕事を複数のサブタスクに分けて並列実行するとき、結果の取りこぼしや裏で動き続ける処理が起きにくくなりました。 + +- **完了をきちんと待つ**: サブタスクを起動したあと、エージェントが全部の完了を待ってから次に進むようになりました。待っている間はタスクがいったん停止して実行枠を手放すので、他の仕事を詰まらせません。 +- **待ち忘れの防止**: 子がまだ走っている最中に「完了」しようとしても、結果を待たずに終わってしまうことはなくなりました(自動で待ってから続けます)。 +- **止めたら子も止まる**: タスクを失敗・キャンセル・リトライしたとき、まだ走っているサブタスクも一緒に止まります。親が消えたのに子だけが裏で GPU を使い続ける、ということがなくなりました(ユーザーの回答待ちで止まっている間は、あとで再開するため子は残します)。 +- **起動を少しずつ**: 一度にたくさんのサブタスクを起動しても、実行枠を一気に奪わないよう少しずつ順番待ちに並べます。 + +詳しくは「[サブ実行・並列実行](./10-subtasks.md)」を参照してください。 + +## 2026-06-26 — Web 検索の不正な呼び出しでタスクが落ちる不具合修正 + +エージェントが検索文字列を付けずに Web 検索ツールを呼んでしまったとき、これまではタスク全体がエラーで停止していました。今後はその検索だけをエラーとして扱い、エージェントが続行できるようにしました。出力が不安定なモデルを使っているときに起きやすかった不具合です。 + ## 2026-06-25 — 最近追加した画面が英語表示に対応 表示言語を English に切り替えたとき、新しめの画面が日本語のまま残っていました。ワークスペース(一覧・詳細・メンバー・カレンダー・ツール設定・アプリ)、ファイル操作(ツールバー・並べ替え・移動ダイアログ・プレビュー)、新規作成時のワークスペース選択などを英語化し、言語設定に合わせて切り替わるようにしました。言語は **設定 → 環境設定** で選べます。 diff --git a/ui/src/content/help/01-intro.md b/ui/src/content/help/01-intro.md index 73d6b28..d8263ad 100644 --- a/ui/src/content/help/01-intro.md +++ b/ui/src/content/help/01-intro.md @@ -68,6 +68,14 @@ MAESTRO に頼める代表的な仕事です。 「ユーザー」「CAPTCHA」タブは管理者にだけ表示されます。 +### コマンドパレット(⌘K / Ctrl+K) + +`⌘K`(Windows / Linux は `Ctrl+K`)で **コマンドパレット** が開きます。画面を横断して、ワークスペースやタスクへのジャンプ・主要アクションの実行を、キーボードだけで検索して呼び出せます。入力で候補を絞り込み、上下キーで選んで Enter で実行します。マウスでタブを辿らずに目的の場所へ飛びたいときに便利です。 + +### 外観(ライト / ダーク) + +画面右上のトグルで、外観を **ライト / ダーク / システム追従** に切り替えられます。選択はこの端末に記憶されます。 + ## はじめての一歩 1. **タスクを作る** — 依頼内容を入力して実行します。書き方のコツは [タスクを作って実行する](./02-tasks.md) を参照 diff --git a/ui/src/content/help/02-tasks.md b/ui/src/content/help/02-tasks.md index dbff1b5..2dc2ff2 100644 --- a/ui/src/content/help/02-tasks.md +++ b/ui/src/content/help/02-tasks.md @@ -27,6 +27,10 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po 依頼が曖昧だと、エージェントが確認を返して `waiting_human` で一時停止することがあります。 +### プロンプトコーチ(依頼文の採点・改善) + +作成ダイアログには、書いた依頼文をその場で評価してくれる **プロンプトコーチ** があります。実行前に依頼文を採点し、何が足りないか(出力形式・観点・制約の不足など)を指摘したうえで、改善した文案を提示します。提案をそのまま入力欄に反映できるので、曖昧な依頼で `waiting_human` に止まる前に、依頼の質を上げられます。入力欄の近くに出る一般的な書き方のヒント(Tips)とは別の、あなたの依頼文に対する個別フィードバックです。 + ### 添付ファイル ダイアログのドロップゾーンにファイルをドラッグ&ドロップ、またはクリックで選択して添付できます。添付したファイルはワークスペースの `input/` に保存され、エージェントが読み込めます。依頼文で「input のファイルを読んで」と明示すると確実です。 diff --git a/ui/src/content/help/03-running.md b/ui/src/content/help/03-running.md index 6caea25..8319b26 100644 --- a/ui/src/content/help/03-running.md +++ b/ui/src/content/help/03-running.md @@ -24,6 +24,10 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。 +### コンテキスト残量ゲージ + +概要タブと入力欄の上に、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が出ます。上限に近づくと色が変わり、限界が近いことが分かります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。 + ## 実行中に指示を追加する(割り込み / interjection) エージェントが動いている最中でも、メッセージを送って指示を追加できます。これを **割り込み(interjection)** と呼びます。 @@ -50,6 +54,8 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み このため、関連する作業は新しいタスクを作り直すより、同じチャットで続けて頼むほうがスムーズです。やり取りの全文は各ワークスペースの `logs/transcript.jsonl` に記録されます。 +**別のタスクタイプ(piece)で続ける**こともできます。タスク詳細の「別の piece で続ける」ボタンから piece を選び直すと、これまでの会話・コメントを引き継いだまま、別のやり方(例: チャットで調べた内容をスライド生成 piece で資料化)で続行できます。 + ## 止める 赤い **「停止」ボタン** を押すと、次のツール呼び出しのチェックポイントでエージェントが停止します。 diff --git a/ui/src/content/help/04-results.md b/ui/src/content/help/04-results.md index 6f96573..9fed239 100644 --- a/ui/src/content/help/04-results.md +++ b/ui/src/content/help/04-results.md @@ -70,6 +70,17 @@ Markdown ファイルのプレビューを開くと、右上に **「PDF / 印 ヒント: ボタンが反応しないときは、ブラウザのポップアップブロックを解除してください(新規タブを開いて印刷するため)。 +## チャットに出るリッチカード(地図・X・YouTube・商品) + +エージェントが地図・SNS・動画・商品などを調べると、結果が**カード**としてチャットに表示されます。素の URL の羅列ではなく、見やすい形にまとまります。 + +- **地図 / 場所**: 検索した場所がカードで並ぶ +- **X (Twitter) 投稿**: 投稿の内容がカードで表示される +- **YouTube 動画**: 動画のサムネイル付きで一覧される +- **Amazon 商品**: 商品情報がカードで並ぶ + +カードはクリックすると拡大表示(モーダル)で詳しく見られます。裏で動いているツール(SearchPlaces / XSearch / SearchYouTube / SearchAmazon など)は [ツールリファレンス](./16-tools.md) を参照してください。 + ## ダウンロードする ファイルタブの各エントリからは、ファイルの実体をダウンロードできます。output に書き出された成果物(レポート・CSV・画像など)はここから手元に保存します。 diff --git a/ui/src/content/help/05-pieces.md b/ui/src/content/help/05-pieces.md index 3efe52a..9886c00 100644 --- a/ui/src/content/help/05-pieces.md +++ b/ui/src/content/help/05-pieces.md @@ -46,7 +46,9 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので | `sns-deep-sweep` | タイムラインや複数の投稿を選別し、1件ずつ個別の担当が深掘りして統合レポートにまとめます。投稿が多くても品質が均一になりやすいのが特長です。単一トピックの SNS の声を手早く知りたいときは「SNS 調査(sns-research)」を使ってください | | `ssh-ops` | SSH 経由でリモートホストに単発オペレーションを実行(health check / config push / log fetch) | | `ssh-console` | 人間と AI が 1 つの PTY セッションを共有する対話的 SSH コンソール。長時間作業や TUI(vim / top / tmux / tail 等)向け | -| `piece-builder` | Piece の設計・作成・編集を行う専用エージェント。「Piece を作って」「ワークフローを自動化したい」に対応 | +| `workspace-app` | ワークスペースのファイルを読み書きする自己完結型の HTML GUI(ミニアプリ)をゼロから作る。「○○のアプリ/画面を作って」向け。詳細は [ワークスペース・アプリ](20-workspace-apps.md) | +| `x-ai-digest` | X (Twitter) から AI・技術系の話題を収集し、深掘りしてダイジェスト記事(Markdown)にまとめる。毎朝など定期実行と相性が良い | +| `piece-builder` | **エージェントの定義(Piece の YAML)そのもの**を設計・作成・編集する専用エージェント。「○○用の piece/エージェントを作って」向け。動く HTML アプリを作りたいときは `workspace-app`、定期実行の仕込みは [スケジュール](06-schedules.md) を使う | `ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。 diff --git a/ui/src/content/help/10-subtasks.md b/ui/src/content/help/10-subtasks.md index 6f25f37..b3aa855 100644 --- a/ui/src/content/help/10-subtasks.md +++ b/ui/src/content/help/10-subtasks.md @@ -3,20 +3,43 @@ id: subtasks title: サブ実行・並列実行 category: advanced order: 100 -keywords: [サブタスク, SpawnSubTask, 並列, waiting_subtasks, research-sub, 分解, delegate, サブエージェント] +keywords: [サブタスク, SpawnSubTask, WaitSubTask, 並列, 直列, waiting_subtasks, 分解, delegate, サブエージェント, 完了待ち] --- # サブ実行・並列実行 -大きな仕事は、エージェントが複数のサブタスクに分解して並列実行できます。「5 社を比較調査して」のように独立した調査項目が並ぶタスクで効果的です。 +大きな仕事は、エージェントが複数のサブ調査に分解して進めます。「5 社を比較調査して」のように独立した調査項目が並ぶタスクで効果的です。 -## どう動くか +分解には 2 つのやり方があります。 -エージェントは `SpawnSubTask` ツールを呼んでサブタスクをキューに追加します。複数回呼べば複数のサブタスクが並列にスケジュールされます。各サブタスクには独立した専用ワークスペースが割り当てられ、別ジョブとして実行されます。 +- **delegate(直列・既定)**: サブ調査を **1 件ずつ順番に** こなします。各サブは綺麗なコンテキストで動くので品質が揃いやすく、GPU・実行枠も軽くて済みます。調査・分析・ブレインストーミング系のタスクは既定でこちらを使います。並列に比べると壁時計時間は少し長くなります。 +- **SpawnSubTask(並列・任意)**: 独立したサブタスクを **別ジョブとして同時に** 走らせます。壁時計時間は縮みますが、その分の実行枠を消費します。**デフォルトでは無効**で、必要なワークスペースだけがツール設定で有効化します(後述)。 -サブタスクを 1 つ以上生成すると、親タスクは **waiting_subtasks** 状態に入ります。すべての子が完了すると親が再開し、子の成果をまとめて最終出力を作ります。 +ふだんは delegate で分解が完結するため、ほとんどのタスクで SpawnSubTask を有効にする必要はありません。 -ジョブの状態遷移については「[タスクの実行と監視](03-running.md)」を参照してください。 +## delegate(直列のサブ調査) + +エージェントが `delegate` ツールを呼ぶと、その場でサブエージェントがインライン実行されます。サブの中間ターンは親のコンテキストに入らず、最終結果だけが親に戻ります。複数の delegate は順番に(直列で)実行されます。 + +delegate は標準で有効なので、特別な設定は不要です。実行中の様子はチャット欄に専用のコンソール枠でリアルタイム表示され、完了後の記録は「概要 > サブ実行」に残ります。 + +## SpawnSubTask(並列の別ジョブ・要有効化) + +`SpawnSubTask` はデフォルト無効のツールです。使うには、ワークスペースの **設定 → ツール** タブで明示的にオンにします(Bash などと同じ「センシティブ/任意有効化」の扱い)。有効にしていないワークスペースでは、エージェントに SpawnSubTask が提示されず、分解は delegate(直列)で行われます。 + +有効にしたうえでエージェントが `SpawnSubTask` を呼ぶと、サブタスクがキューに追加されます。複数回呼べば複数のサブタスクが並列にスケジュールされます。各サブタスクには独立した専用ワークスペースが割り当てられ、別ジョブとして実行されます。 + +サブタスクを起動したあと、エージェントは `WaitSubTask` を呼んで全部の完了を待ちます。このとき親タスクは **waiting_subtasks** 状態に入り、いったん停止して実行枠(worker)を手放します(待っている間に他の仕事が詰まりません)。すべての子が完了すると親が再開し、子の成果をまとめて最終出力を作ります。 + +ツール設定の詳しい場所は [ワークスペースとメンバー](./21-workspaces.md) の「ツールポリシー」を参照してください。ジョブの状態遷移については「[タスクの実行と監視](03-running.md)」を参照してください。 + +## サブタスクまわりの安全策 + +サブタスクを取りこぼしたり、止めたはずの処理が裏で動き続けたりしないよう、いくつかの安全策が働きます。 + +- **待ち忘れの防止**: 子がまだ走っている最中にエージェントが「完了」しようとしても、結果を待たずに終わってしまうことはありません。自動でいったん停止して、全部の完了を待ってから続けます。 +- **止めたら子も止まる**: 親タスクが失敗・キャンセル・リトライしたとき、まだ走っているサブタスクも一緒に止まります。親が消えたのに子だけが裏で動き続けて GPU を使う、ということがなくなりました。ただし、ユーザーの回答待ち(割り込み待ち)で止まっている間は、あとで再開して結果を使うため子は止めません。 +- **起動を少しずつ**: 一度に大量のサブタスクを起動しても、順番待ちの列に少しずつ(既定で 1 秒間隔)並べます。一団が実行枠を一気に奪って他のタスクを後回しにしてしまうのを防ぎます。間隔は `config.yaml` の `subtasks.spawn_stagger_ms` で調整できます(`0` で無効)。 ## SpawnSubTask の引数 @@ -26,9 +49,7 @@ keywords: [サブタスク, SpawnSubTask, 並列, waiting_subtasks, research-sub | instruction | 何を調査・実行し、どんな形式で `output/` に書くかの具体的な指示 | | piece | 使用するピース(省略時は `general`) | -`piece` には `general` / `research` / `brainstorming` / `orchestrated` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。 - -調査系のサブタスクには、専用ピース **research-sub**(dig → analyze → verify の 3 ステップ)が使われます。research-sub はそれ以上のサブタスク分解を行わず、調査をその場で完結させます。 +`piece` には `general` / `research` / `brainstorming` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。 ## サブ実行の確認 @@ -67,6 +88,8 @@ subtasks: ## TIP -> サブタスクは「独立して並列実行できる」項目に向きます。前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。 +> ふだんの分解は delegate(直列)で十分です。SpawnSubTask の並列化は、独立したテーマが複数あって壁時計時間を縮めたいときだけ、ツール設定で有効にして使います。 + +> 前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。 > サブタスクへの instruction には、成果物を `output/` にどう書くかまで具体的に指定すると、親がまとめやすくなります。 diff --git a/ui/src/content/help/13-mcp.md b/ui/src/content/help/13-mcp.md index ac703af..bb374d8 100644 --- a/ui/src/content/help/13-mcp.md +++ b/ui/src/content/help/13-mcp.md @@ -97,6 +97,15 @@ MCP ツール呼び出しの生レスポンスは、各ジョブのワークス デバッグや監査の際にこれらを確認できます。 +## 他のワークスペースから取り込む + +同じ MAESTRO 内の別ワークスペースに登録済みの MCP サーバーを、まとめてコピーできます。**設定 → MCP** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。 + +- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること +- **スキップ**: 同じ id または同じ URL のサーバーがすでにある場合は自動でスキップされ、既存の設定は上書きされません +- **再認証が必要**: OAuth 方式のサーバーは per-user トークンを移せません。取り込み後に「連携」ボタンでブラウザ認証をやり直してください。API key 方式はそのまま使えます +- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません + ## トラブルシューティング - ツールが見えない: そのワークスペースに MCP サーバーが登録されていない、OAuth サーバーが未連携、ワークスペースのツール設定で MCP が無効、`MCP_ENCRYPTION_KEY` 未設定、またはタスク作成時に「MCP ツールを無効化」を ON にした、のいずれか diff --git a/ui/src/content/help/14-ssh.md b/ui/src/content/help/14-ssh.md index 993f6cc..20433d1 100644 --- a/ui/src/content/help/14-ssh.md +++ b/ui/src/content/help/14-ssh.md @@ -15,7 +15,12 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作 | ssh-ops | 単発コマンド実行・ファイル転送 (health check / config push / log fetch) | `ssh-ops` | | ssh-console | 対話的な PTY シェル (vim / tmux / tail など長時間作業) | `ssh-console` | -いずれもデフォルトでは無効です。admin が `config.yaml` で有効化し (`ssh.enabled: true`、コンソールは加えて `ssh.console.enabled: true`)、SSH 接続を登録したうえでジョブの所有者に grant を付与する必要があります。詳しくは [管理者ガイド](./19-admin.md) を参照してください。 +いずれもデフォルトでは無効です。使い始めるには次の 2 つの条件を満たす必要があります。 + +1. **admin が SSH を有効化する** — `config.yaml` で `ssh.enabled: true`(コンソールは加えて `ssh.console.enabled: true`)を設定します。詳しくは [管理者ガイド](./19-admin.md) を参照してください。 +2. **ワークスペースポリシーで SSH を有効にし、接続を登録する** — ワークスペースの **設定 → ツール** で `ssh` カテゴリをオンにし、**設定 → SSH** で接続を登録します。エージェントはそのワークスペースに登録された接続のみ使えます(他のワークスペースの接続には触れません)。 + +> **スコープ設計**: 接続の登録がオプトインです。ワークスペースに登録された接続だけがそのワークスペースのタスクから見え、別ワークスペースへの水平移動はできません。MCP サーバーのスコープと同じ考え方です。 ## ssh-ops: 単発実行とファイル転送 @@ -65,13 +70,23 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作 ## どう動かすか -1. admin が SSH を有効化し、接続と grant を整える -2. ユーザーが該当接続を登録 (個人接続の場合) して Test でホストキーを検証 -3. `ssh-ops` または `ssh-console` を使うタスクを作成して実行 -4. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作 +1. admin が `config.yaml` で SSH を有効化する +2. ワークスペースの **設定 → ツール** で `ssh` カテゴリをオンにする +3. **設定 → SSH** で接続を登録し、Test でホストキーを検証する +4. `ssh-ops` または `ssh-console` を使うタスクを作成して実行する +5. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作する Piece の選び方や `allowed_tools` の考え方は [piece を使う・作る](./05-pieces.md) を参照してください。 +## 他のワークスペースから取り込む + +同じ MAESTRO 内の別ワークスペースに登録済みの SSH 接続を、まとめてコピーできます。**設定 → SSH** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。 + +- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること +- **スキップ**: 同じ label・同じ host/user の組み合わせがすでにある場合は自動でスキップされ、既存の接続は上書きされません +- **秘密鍵ごとコピー**: 秘密鍵は移し先ワークスペースの鍵で再暗号化されて保存されます。取り込み後すぐに使えますが、ホストキー検証(TOFU)が未完了の接続は Test を押して検証してください +- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません + ## よくあるエラー | エラーコード | 意味 | 対処 | diff --git a/ui/src/content/help/15-llm-gateway.md b/ui/src/content/help/15-llm-gateway.md index 3c343d2..af39473 100644 --- a/ui/src/content/help/15-llm-gateway.md +++ b/ui/src/content/help/15-llm-gateway.md @@ -76,6 +76,7 @@ Gateway / Worker いずれも Prometheus 互換の `/metrics` を公開できま 上部の **使用量** タブでは、LLM のトークン使用量を日次で確認できます。Gateway 経由と Direct(各 worker が直接接続)の両方を合算し、お使いの環境のローカル日付で集計します。 - **内訳の切り替え**: 経路・モデル・バックエンド・ユーザー・組織で分解して見られます +- **時間帯別プロファイル**: 0〜23 時の時間帯ごとの使用傾向も確認でき、いつ負荷が集中するかが分かります - **集計の対象**: 入力 / 出力トークンとリクエスト数。ユーザー別の一覧も出ます - Virtual Keys のキー別課金パネル(admin の Gateway 設定内)とは **別集計** です。Usage タブは「実際に消費したトークン量の可視化」、課金パネルは「キーごとの予算管理」と捉えてください diff --git a/ui/src/content/help/16-tools.md b/ui/src/content/help/16-tools.md index aa66c45..b4f548c 100644 --- a/ui/src/content/help/16-tools.md +++ b/ui/src/content/help/16-tools.md @@ -22,6 +22,7 @@ movement の開始時には、その movement で使えるツールの一覧と |---|---|---| | ファイル / シェル | ワークスペースのファイル操作とコマンド実行 | Read / Write / Edit / Bash / Glob / Grep | | Web / 検索 | Web 検索・取得・ダウンロード | WebSearch / WebFetch / DownloadFile | +| 技術ドキュメント | Microsoft Learn の公式ドキュメントを検索・取得 | SearchMicrosoftLearn / FetchMicrosoftLearn | | ブラウザ | 実ブラウザでのページ操作 | BrowseWeb | | Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx | | データ | SQLite データベース操作 | SQLite | @@ -32,7 +33,8 @@ movement の開始時には、その movement で使えるツールの一覧と | SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend | | オーケストレーション | サブタスクの生成 | SpawnSubTask | | 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections | -| メディア | 文字起こし・動画字幕 | TranscribeAudio / GetYouTubeTranscript | +| メディア | 文字起こし・動画検索・字幕取得 | TranscribeAudio / SearchYouTube / GetYouTubeTranscript | +| カレンダー | ワークスペースのカレンダーへ予定を登録・参照 | AddCalendarEvent / ListCalendarEvents | | その他 | X(旧Twitter)検索・Amazon 検索など | XSearch / SearchAmazon | SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。 @@ -46,7 +48,9 @@ SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照 - `RequestTool` — タスクに足りないツールの利用を申請する。チャット上でオーナーが承認すると、その場で使えるようになる - `ReadUserMemory` / `UpdateUserMemory` — ワークスペースのメモリを読み書きする(→[メモリ](./12-memory.md)) - `CreateChecklist` / `CheckItem` / `GetChecklist` — タスク内の進捗チェックリスト +- `MissionUpdate` — 長時間タスクで、目標と現在地(進捗)をユーザーに途中報告するためのピン留めメモを更新する - `GetMyOrchestratorState` — 自分が今どのワークスペース・タスクで動いているかを把握する +- `ReadAppDoc` / `ListAppDocs` — アプリ内のヘルプ・ドキュメントをエージェント自身が読む(`#help` のヘルプ応答などで使われる) 各ツールの function definition は 1 文に圧縮されているため、詳しい使い方はこの `ReadToolDoc` で取得する設計です。 diff --git a/ui/src/content/help/17-settings.md b/ui/src/content/help/17-settings.md index f77a21e..72e7f24 100644 --- a/ui/src/content/help/17-settings.md +++ b/ui/src/content/help/17-settings.md @@ -97,6 +97,16 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。 |-----------|------| | HTTPS / TLS | `server.tls.*` — アプリ内 TLS 終端・証明書・HTTP→HTTPS リダイレクト・HSTS。詳細は下の「[HTTPS とリダイレクト](#https-とリダイレクト)」 | +## ブランディング(見た目のカスタマイズ) + +System グループの **Branding** で、アプリ全体の見た目を組織向けに変えられます。 + +- **アプリ名**: タイトルや画面に出る名称 +- **ロゴ / favicon**: 画像をアップロードして差し替え(ブラウザのタブに出るアイコンも変わる) +- **アクセント色**: ボタンやリンクなどの基調色 + +ここはアプリ**全体**の設定です。個々の案件ワークスペースに付ける「ブランド色」(一覧の色帯など)は別物で、各ワークスペースの設定で変えます(→[ワークスペースとメンバー](./21-workspaces.md))。 + ## Save & Apply の流れ フォームの値を変更しても、すぐには反映されません。 diff --git a/ui/src/content/help/19-admin.md b/ui/src/content/help/19-admin.md index e3c74b2..c71c081 100644 --- a/ui/src/content/help/19-admin.md +++ b/ui/src/content/help/19-admin.md @@ -3,12 +3,16 @@ id: admin title: ユーザー管理 / 安全性 category: admin order: 190 -keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査] +keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査, セットアップ, 初回設定, ウィザード, CAPTCHA, noVNC] --- # ユーザー管理 / 安全性 (admin) -このページは admin が行う運用作業をまとめます。ユーザーの承認・権限付与、公開範囲モデル、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。 +このページは admin が行う運用作業をまとめます。初回セットアップ、ユーザーの承認・権限付与、公開範囲モデル、CAPTCHA の手動解決、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。 + +## 初回セットアップウィザード + +インストール直後(例: `docker compose up` の後)に初めて開くと、全画面の **セットアップウィザード** が表示されます。LLM の接続先・公開ポート・認証方式といった最低限の設定を順番に入力し、最後にまとめて適用します。ここを通すと、後から **設定** タブの各セクションで同じ項目を個別に調整できます。一度設定が済めばウィザードは出ません。 ## ユーザー管理 @@ -69,6 +73,10 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ - 権限チェックは `buildVisibilityWhere(user, alias)` を一覧・取得クエリに差し込む形で一元化されている - 親 → 子へ公開範囲がコピーされる (タスク → その spawn job、スケジュール → spawn job、親 job → 子 subtask) +## CAPTCHA の手動解決(CAPTCHA タブ) + +エージェントがブラウザ操作中に CAPTCHA や追加認証に突き当たって止まることがあります。admin だけに見える **CAPTCHA** タブには、解決待ちのブラウザセッションが集まり、**noVNC のライブ画面**で人が手動で突破できます。画面は別ウィンドウ(ピクチャー・イン・ピクチャーで前面固定)にして、別作業をしながら対応することもできます。解決して取得したログイン状態(cookie / storage)は暗号化保存され、以降の自動ブラウズで再利用されます(→[ブラウザ操作](./09-userfolder.md) のセッション)。 + ## 安全性: Bash 実行モード エージェントの `Bash` ツールの隔離は、`config.yaml` の **2 つの独立したキー**で制御します。どちらも設定 UI には出ない **config ファイル専用キー**です(危険なセキュリティ姿勢キーは UI トグルにせず、config 直編集 + 再起動を要求する方針)。 @@ -110,6 +118,14 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ > 本番は `bash_sandbox: always` + パッケージのプリベイクを推奨。bwrap が使えないホストでは `auto`(hardened フォールバック)になるが、テナント間分離は弱くなる点に注意。 +## ワーカー / GPU の占有ユーザー + +サイドパネルの **ワーカー** 一覧では、実行中の各ワーカー(GPU)に、いまそのワーカーを占有しているユーザー名(`使用中: ...`)が表示される。複数ユーザーが同じワーカーで同時に実行している場合は連名で出る。「誰のタスクで GPU が埋まっているか」をこの画面だけで把握できる。 + +- **この表示は管理者にだけ見える**。一般ユーザーには稼働状況(実行中/待機)と空きスロットのみが見え、ユーザー名・ジョブ内容は出ない(マルチテナントのプライバシー保護) +- 表示名は登録ユーザーの名前。名前が取れない場合(旧データ等)はユーザー ID で代替表示する +- ユーザー名のとなりに、ジョブの**種別バッジ**(`task_kind`)が付く。通常タスク(`agent`)にはバッジを付けず、`reflection`(学習)など通常以外のときだけタグを出すので、「学習で埋まっているだけ」を区別できる + ## 監査ログ - ジョブ実行に関する操作は `audit_log` テーブル (`action` / `actor` / `detail` / `created_at`) に記録される diff --git a/ui/src/content/help/21-workspaces.md b/ui/src/content/help/21-workspaces.md index 96cea47..a30444a 100644 --- a/ui/src/content/help/21-workspaces.md +++ b/ui/src/content/help/21-workspaces.md @@ -35,6 +35,8 @@ keywords: [ワークスペース, 個人ワークスペース, 案件ワーク ワークスペースの「チャット」一覧には、検索・絞り込みバーがあります。キーワード(タイトル・本文・Piece・作成者名)での検索、状態(実行中・待機・失敗など)のタブ絞り込み、並び替え(更新順・状態・タイトル)が使えます。共有ワークスペースで「自分 / 他のメンバー」を切り替えている場合は、その範囲の中で絞り込みます。 +**「自分 / 他のメンバー」の切り替えについて**: 共有ワークスペースでは、メンバーが作成したチャットを「他のメンバー」で見られます。個人ワークスペースは作成者本人だけのものなので、通常この切り替えは出ません。例外として**管理者(admin)**は、個人ワークスペースの「他のメンバー」から他ユーザーの個人ワークスペースの内容を確認できます(全ユーザーの監視のため)。一般ユーザーには、共有されていない他人のチャットは見えません。 + ## ファイルを置く(エージェントの入力にする) ワークスペースの **Files** にファイルを置くと、そのワークスペースのチャットで **エージェントの入力**になります。チャットを始めると、エージェントへ「ワークスペースの既存ファイル一覧」が自動で渡るので、`input/` フォルダに入れなくても置いたファイルは認識されます。新しく作ったワークスペースには `input/`・`output/` フォルダが最初から用意されています。ファイルがまだ 1 つもないときは、Files にその旨の案内が控えめに出ます。 diff --git a/ui/src/i18n/locales/en/dashboard.json b/ui/src/i18n/locales/en/dashboard.json index a4b4ba4..5c3f3fa 100644 --- a/ui/src/i18n/locales/en/dashboard.json +++ b/ui/src/i18n/locales/en/dashboard.json @@ -14,7 +14,8 @@ "fetchError": "Failed to fetch", "empty": "No workers configured", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "inUse": "In use:" }, "tabBar": { "expand": "Expand", diff --git a/ui/src/i18n/locales/en/detail.json b/ui/src/i18n/locales/en/detail.json index fb666b6..5192ce5 100644 --- a/ui/src/i18n/locales/en/detail.json +++ b/ui/src/i18n/locales/en/detail.json @@ -24,7 +24,7 @@ "cancel": "Cancel", "starting": "Starting..." }, "focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" }, - "feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" }, + "feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit", "tags": { "goodAccuracy": "Accurate output", "goodFormat": "Well-formatted", "goodUnderstanding": "Understood the instructions", "goodSpeed": "Good speed", "badAccuracy": "Inaccurate output", "badFormat": "Poorly formatted", "badMismatch": "Result differed from the instructions", "badUnnecessary": "Did unnecessary work", "badStalled": "Stalled / too many ASKs" } }, "title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" }, "mission": { "pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save", diff --git a/ui/src/i18n/locales/en/spaces.json b/ui/src/i18n/locales/en/spaces.json index 5df49e4..e8ac8c2 100644 --- a/ui/src/i18n/locales/en/spaces.json +++ b/ui/src/i18n/locales/en/spaces.json @@ -373,7 +373,8 @@ "sensitiveNote": { "ssh": "Allows connecting to remote shells. Commands can be executed on the target servers.", "browser": "Allows real browser actions. Visiting websites and operating forms will be performed.", - "Bash": "Allows arbitrary command execution. Any shell command can run on the host system." + "Bash": "Allows arbitrary command execution. Any shell command can run on the host system.", + "SpawnSubTask": "Allows decomposition into parallel subtasks (separate jobs). Each child occupies a worker and a GPU slot. The serial 'delegate' tool is usually enough; enable this only when parallel execution is genuinely required." } } } diff --git a/ui/src/i18n/locales/ja/dashboard.json b/ui/src/i18n/locales/ja/dashboard.json index b680808..1597443 100644 --- a/ui/src/i18n/locales/ja/dashboard.json +++ b/ui/src/i18n/locales/ja/dashboard.json @@ -14,7 +14,8 @@ "fetchError": "取得に失敗しました", "empty": "Worker が設定されていません", "expand": "展開する", - "collapse": "折りたたむ" + "collapse": "折りたたむ", + "inUse": "使用中:" }, "tabBar": { "expand": "展開", diff --git a/ui/src/i18n/locales/ja/detail.json b/ui/src/i18n/locales/ja/detail.json index 5c16d72..443c39b 100644 --- a/ui/src/i18n/locales/ja/detail.json +++ b/ui/src/i18n/locales/ja/detail.json @@ -24,7 +24,7 @@ "cancel": "キャンセル", "starting": "起動中..." }, "focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" }, - "feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" }, + "feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信", "tags": { "goodAccuracy": "出力の精度が高い", "goodFormat": "フォーマットが適切", "goodUnderstanding": "指示をよく理解していた", "goodSpeed": "速度が適切だった", "badAccuracy": "出力の精度が低い", "badFormat": "フォーマットが不適切", "badMismatch": "指示と違う結果になった", "badUnnecessary": "不要な作業をしていた", "badStalled": "途中で止まった / ASKが多すぎた" } }, "title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" }, "mission": { "pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存", diff --git a/ui/src/i18n/locales/ja/spaces.json b/ui/src/i18n/locales/ja/spaces.json index a9b2e84..4512815 100644 --- a/ui/src/i18n/locales/ja/spaces.json +++ b/ui/src/i18n/locales/ja/spaces.json @@ -373,7 +373,8 @@ "sensitiveNote": { "ssh": "遠隔シェルへの接続を許可します。接続先サーバーでのコマンド実行が可能になります。", "browser": "実ブラウザ操作を許可します。ウェブサイトへのアクセスやフォーム操作が実行されます。", - "Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。" + "Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。", + "SpawnSubTask": "並列サブタスク(別ジョブ)への分解を許可します。子ごとにワーカーと GPU スロットを占有します。通常は直列の delegate で十分です。本当に並列実行が必要なときだけ有効化してください。" } } } diff --git a/ui/src/lib/spaceTasks.test.ts b/ui/src/lib/spaceTasks.test.ts index c603a96..77c681f 100644 --- a/ui/src/lib/spaceTasks.test.ts +++ b/ui/src/lib/spaceTasks.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect } from 'vitest'; import { filterTasksForSpace, countRunningTasksForSpace } from './spaceTasks'; import type { LocalTask } from '../api'; -function task(id: number, spaceId: string | null, updatedAt: string, status?: string): LocalTask { +function task( + id: number, + spaceId: string | null, + updatedAt: string, + status?: string, + ownerId: string | null = 'u1', +): LocalTask { return { id, title: `task ${id}`, @@ -15,6 +21,7 @@ function task(id: number, spaceId: string | null, updatedAt: string, status?: st state: 'queued', workspacePath: null, spaceId, + ownerId, createdAt: updatedAt, updatedAt, ...(status ? { latestJob: { id: `j${id}`, status } } : {}), @@ -41,6 +48,29 @@ describe('filterTasksForSpace', () => { expect(result.map(t => t.id)).toEqual([1, 2]); }); + it('includes other users\' personal-space tasks when caseSpaceIds is supplied (admin monitoring)', () => { + // 個人バケツ = null か case でない space_id。caseSpaceIds={sp-a} を渡すと + // 他ユーザーの個人スペース(personal-u2) も含まれ、case スペース(sp-a) は除外される。 + const tasks = [ + task(1, null, '2026-01-05', undefined, 'u1'), // 自分の legacy null + task(2, 'personal-u1', '2026-01-04', undefined, 'u1'), // 自分の個人スペース + task(3, 'personal-u2', '2026-01-03', undefined, 'u2'), // 他ユーザーの個人スペース + task(4, 'sp-a', '2026-01-02', undefined, 'u2'), // case スペース → 除外 + ]; + const caseSpaceIds = new Set(['sp-a']); + const result = filterTasksForSpace(tasks, 'personal-u1', true, caseSpaceIds); + expect(result.map(t => t.id)).toEqual([1, 2, 3]); + }); + + it('without caseSpaceIds, personal space keeps only this-space-or-null (back-compat)', () => { + const tasks = [ + task(1, null, '2026-01-03', undefined, 'u1'), + task(2, 'personal-u1', '2026-01-02', undefined, 'u1'), + task(3, 'personal-u2', '2026-01-01', undefined, 'u2'), // 他ユーザー個人 → 集合無しでは除外 + ]; + expect(filterTasksForSpace(tasks, 'personal-u1', true).map(t => t.id)).toEqual([1, 2]); + }); + it('returns empty when no task matches', () => { expect(filterTasksForSpace([task(1, 'sp-x', '2026-01-01')], 'sp-y')).toEqual([]); }); @@ -80,6 +110,19 @@ describe('countRunningTasksForSpace', () => { expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(2); }); + it('scopes the personal badge to the viewer when viewerId is given (admin sees all rows)', () => { + // admin のリスト API は全件 (1=1) を返すため、owner 絞りが無いと他ユーザーの + // null タスクが個人スペースのバッジに混ざる。viewerId 指定で本人のみ数える。 + const tasks = [ + task(1, null, '2026-01-01', 'running', 'u1'), // 自分 + task(2, null, '2026-01-02', 'running', 'u2'), // 他ユーザー → 除外 + task(3, 'personal', '2026-01-03', 'running', 'u1'), // 自分の個人スペース id + ]; + expect(countRunningTasksForSpace(tasks, personalSpace, 'u1')).toBe(2); + // viewerId 未指定なら従来どおり全件 (no-auth 単独利用)。 + expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(3); + }); + it('ignores tasks with no latestJob', () => { const tasks = [task(1, 'sp-a', '2026-01-01'), task(2, 'sp-a', '2026-01-02', 'running')]; expect(countRunningTasksForSpace(tasks, caseSpace)).toBe(1); diff --git a/ui/src/lib/spaceTasks.ts b/ui/src/lib/spaceTasks.ts index 551eec6..77440d5 100644 --- a/ui/src/lib/spaceTasks.ts +++ b/ui/src/lib/spaceTasks.ts @@ -6,15 +6,29 @@ import type { LocalTask } from '../api'; * `spaceId` 一致だけを残す。新しい更新が上に来るよう updatedAt 降順で並べる。 * * spec §7: `space_id` が null のタスクは個人スペースに属するものとして扱う。 - * 個人スペースを表示しているとき (`isPersonalSpace`) は null タスクも含める。 + * + * 個人スペース (`isPersonalSpace`) の判定は「個人バケツ」= legacy の null タスク、 + * または **case スペースでない `space_id`**(= 誰かの個人スペース)に属するタスク。 + * `caseSpaceIds`(= 閲覧可能な case スペースの id 集合)を渡すと、他ユーザーの + * 個人スペース(具体 id 持ち)も個人バケツに含められる。これは admin が他ユーザーの + * 個人ワークスペースを監視するために必要(呼び出し側で owner 絞りを併用する)。 + * + * `caseSpaceIds` 未指定時は後方互換で「この個人スペース or null」のみを残す。 + * 個人スペースかどうかに関わらず、所有者での絞り込みは行わない(呼び出し側の責務)。 */ export function filterTasksForSpace( tasks: LocalTask[], spaceId: string, isPersonalSpace = false, + caseSpaceIds?: ReadonlySet, ): LocalTask[] { return tasks - .filter(t => t.spaceId === spaceId || (isPersonalSpace && t.spaceId == null)) + .filter(t => { + if (!isPersonalSpace) return t.spaceId === spaceId; + if (t.spaceId == null) return true; + if (caseSpaceIds) return !caseSpaceIds.has(t.spaceId); + return t.spaceId === spaceId; + }) .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); } @@ -22,15 +36,23 @@ export function filterTasksForSpace( * 指定スペースに属し、いま実行中 (latestJob.status === 'running') のタスク件数。 * null の space_id は個人スペースに属するものとして数える (filterTasksForSpace と同じ規則)。 * リスト API はスペースで絞らないため、クライアント保持の全件から数える。 + * + * 個人スペースのバッジは**閲覧者本人の実行中のみ**数える。admin はリスト API が全件 + * (1=1) を返すため、owner 絞りをしないと他ユーザーの null タスクがレールのバッジに + * 混ざってしまう。`viewerId` を渡すと個人スペースで owner 一致のみに絞る。未指定 + * (no-auth 単独利用) は全件が本人のものなので従来どおり絞らない。 */ export function countRunningTasksForSpace( tasks: LocalTask[], space: { id: string; kind: 'personal' | 'case' }, + viewerId?: string | null, ): number { const isPersonal = space.kind === 'personal'; - return tasks.filter( - t => - t.latestJob?.status === 'running' && - (t.spaceId === space.id || (isPersonal && t.spaceId == null)), - ).length; + return tasks.filter((t) => { + if (t.latestJob?.status !== 'running') return false; + const inSpace = t.spaceId === space.id || (isPersonal && t.spaceId == null); + if (!inSpace) return false; + if (isPersonal && viewerId != null && t.ownerId !== viewerId) return false; + return true; + }).length; }