sync: update from private repo (c764df2f)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-29 01:32:25 +00:00
parent b857c33ef6
commit a6d1879d63
75 changed files with 3874 additions and 334 deletions

View File

@ -196,6 +196,7 @@ ask:
subtasks: subtasks:
max_depth: 2 # SpawnSubTask のネスト最大深度 max_depth: 2 # SpawnSubTask のネスト最大深度
max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数 max_per_parent: 10 # 1 ジョブが生成できるサブタスクの最大数
spawn_stagger_ms: 1000 # SpawnSubTask の連続発火を間引く遅延(ms)。一斉着地で GPU 優先順位を飛び越えるのを防ぐ。0 で無効
# ─── Context (LLM コンテキスト管理) ─────────────────────────── # ─── Context (LLM コンテキスト管理) ───────────────────────────
# context: # context:

View File

@ -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: '<SENTINEL>', 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`(待機ジョブの停車・再開)

View File

@ -2,6 +2,8 @@
サブエージェントを同期的にインライン実行して、重い処理を委譲し本体のコンテキストを節約する。サブエージェントの中間ターンは親に見えないため、複雑な作業の最終結果だけを取得できる。 サブエージェントを同期的にインライン実行して、重い処理を委譲し本体のコンテキストを節約する。サブエージェントの中間ターンは親に見えないため、複雑な作業の最終結果だけを取得できる。
**タスク分解の既定手段**。research / general / brainstorming など調査・分析系のピースは、重いサブ調査を Delegate で **1 件ずつ直列に** こなす設計になっている。並列の別ジョブSpawnSubTaskはデフォルト無効で、本当に並列が必要なワークスペースだけが **設定 → ツール** タブで明示的に有効化する。ほとんどのタスクは Delegate で分解が完結するため、まず Delegate を検討する。
## 基本 ## 基本
```js ```js
@ -27,12 +29,18 @@ Delegate({
- 「特定キーワードで 30 件検索し, 記事要約→集約」→ 重い WebFetch も delegate 内で完結 - 「特定キーワードで 30 件検索し, 記事要約→集約」→ 重い WebFetch も delegate 内で完結
- 「複数 PDF を OCR → テキスト抽出 → 解析」→ 前処理を delegate, 後続は親が実行 - 「複数 PDF を OCR → テキスト抽出 → 解析」→ 前処理を delegate, 後続は親が実行
### SpawnSubTask が向いているケース ### SpawnSubTask が向いているケースopt-in
- **複数の独立したテーマ**A, B, C に分解)→ 並列実行が必須 > SpawnSubTask はデフォルト無効。使うにはワークスペースの **設定 → ツール** タブで有効化するBash などと同じセンシティブ扱いの opt-in ツール)。有効化していないワークスペースでは Delegate での直列分解だけになる。
並列の別ジョブが本当に効く、次のようなケースに限って有効化を検討する:
- **複数の独立したテーマ**A, B, C に分解)で、壁時計時間の短縮が重要
- **サブタスク間に依存がない**A が完了してから B という制約がない) - **サブタスク間に依存がない**A が完了してから B という制約がない)
- **各サブの成果物がそれぞれ意味を持つ**(集約不要または集約が簡単) - **各サブの成果物がそれぞれ意味を持つ**(集約不要または集約が簡単)
GPU・実行枠を多く消費するため、逐次で足りる場合は Delegate を優先する。
### Delegate が向かないケース ### Delegate が向かないケース
- **対話が必要**ASK ツールを呼ぶ)→ 子の ASK は親に bubbles up する - **対話が必要**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 段階目以上は危険) - **ネスト深さ**: 約 2 レベルdelegate 内からさらに delegate を呼ぶのは許可だが, 3 段階目以上は危険)
- **完了待ちブロック**: 親は子が完了するまでブロックされるため、1 回の delegate は焦点を絞ること - **完了待ちブロック**: 親は子が完了するまでブロックされるため、1 回の delegate は焦点を絞ること
- **ASK 必須情報**: サブエージェントが ASK を呼んだら, 親に `[delegate 要追加情報] ...` と bubble up される。親が回答を `transition({lessons: "..." })` で与える - **ASK 必須情報**: サブエージェントが ASK を呼んだら, 親に `[delegate 要追加情報] ...` と bubble up される。親が回答を `transition({lessons: "..." })` で与える

View File

@ -2,6 +2,10 @@
タスクを並列サブタスクに分解して実行する。各サブタスクは独立した workerジョブで動き、完了後に親タスクが結果を集約する。 タスクを並列サブタスクに分解して実行する。各サブタスクは独立した workerジョブで動き、完了後に親タスクが結果を集約する。
> **デフォルト無効opt-in**。SpawnSubTask は Bash などと同じセンシティブ扱いのツールで、初期状態ではエージェントに提示されない。使うにはワークスペースの **設定 → ツール** タブで明示的に有効化する。有効化していない場合、SpawnSubTask は呼べず、分解は [`Delegate`](./delegate.md)(直列・インプロセス)で行う。
>
> タスク分解の既定手段は Delegate直列。research / general / brainstorming などのピースは Delegate でサブ調査を 1 件ずつこなす。SpawnSubTask は「壁時計時間の短縮のために独立テーマを本当に並列で走らせたい」ワークスペースだけが有効化する。
## 基本 ## 基本
```js ```js
@ -16,11 +20,14 @@ SpawnSubTask({
## いつ使うか ## いつ使うか
まず Delegate直列で足りるかを検討する。SpawnSubTask は opt-in で有効化したうえで、**並列の別ジョブで壁時計時間を縮めたい**場合に限って使う。
### 並列分解が効果的なケース ### 並列分解が効果的なケース
- 2 つ以上の **独立したテーマ**(互いに参照しない) - 2 つ以上の **独立したテーマ**(互いに参照しない)
- 各テーマが軽くなく、調査・処理に時間がかかる - 各テーマが軽くなく、調査・処理に時間がかかる
- 分解後の各タスクが単独でも意味を持つ成果物になる - 分解後の各タスクが単独でも意味を持つ成果物になる
- 壁時計時間の短縮が GPU・実行枠の追加消費に見合う
例: 例:
- 「3 つの製品比較レポート」→ 製品ごとに 3 サブタスク - 「3 つの製品比較レポート」→ 製品ごとに 3 サブタスク
@ -47,11 +54,66 @@ SpawnSubTask({
- 省略時: 親と同じ classifier ロジックで自動選択 - 省略時: 親と同じ classifier ロジックで自動選択
- 明示する場合: `research`, `general`, `office-process` 等の piece 名を指定 - 明示する場合: `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/` 以下にサブタスクの成果物がある - `subtasks/{index}/output/` 以下にサブタスクの成果物がある
- Read で参照して集約レポートを作成する
## 制限 ## 制限

View File

@ -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 Auth Token**: ブラウザの `auth_token` cookie の値
- **X ct0**: ブラウザの `ct0` cookie の値 - **X ct0**: ブラウザの `ct0` cookie の値
@ -14,6 +15,34 @@ twitter-cli を動かすには Cookie 認証が必要。Settings UI の "Tools"
設定が無いと「認証エラー」で失敗する。 設定が無いと「認証エラー」で失敗する。
## バックエンド CLIx-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 — 投稿検索 ## XSearch — 投稿検索
```js ```js

View File

@ -4,7 +4,7 @@ description: |
選ぶべき場合: 「どうすべきか」「どの方針が良いか」を多角的に検討する必要がある 選ぶべき場合: 「どうすべきか」「どの方針が良いか」を多角的に検討する必要がある
選ぶべきでない場合: 答えが調査で明確になるタスク、具体的な成果物の作成が主目的 選ぶべきでない場合: 答えが調査で明確になるタスク、具体的な成果物の作成が主目的
max_movements: 999 max_movements: 999
initial_movement: decompose initial_movement: delegate_research
triggers: triggers:
keywords: keywords:
@ -15,59 +15,42 @@ triggers:
- アイデア出し - アイデア出し
movements: movements:
- name: decompose - name: delegate_research
edit: false edit: true
persona: facilitator persona: facilitator
instruction: | instruction: |
課題を分析し、複数の視点から検討すべきポイントを特定してください。 課題を複数の視点に分解し、各視点を delegate で1件ずつ直列に検討させ、
最後に推奨方針へ統合する。重い検討は各サブに任せ、自分の文脈は軽く保つ。
1. タスクの指示を注意深く読み、何が求められているかを理解する 手順:
2. 検討すべき視点や切り口を 2〜5 個に分解する 1. タスクの指示を注意深く読み、検討すべき視点や切り口を 2〜5 個に分解する
- : 技術的実現性、コスト/リソース、ユーザーへの影響、リスク、長期的な拡張性 - : 技術的実現性、コスト/リソース、ユーザーへの影響、リスク、長期的な拡張性
3. 各視点ごとに SpawnSubTask で独立した調査・検討タスクを作成する 各視点に上から 1 始まりの連番を振る1, 2, 3 ...)。
- piece は "research-sub" を指定する 2. 各視点について delegate を1回ずつ呼ぶ1件ずつ順番に・直列
- instruction には「この視点で分析し、output/analysis.md に結論と根拠を書いてください」と具体的に記述 delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、
- 各サブタスクは異なる分析レンズを持つよう明確に指定する このタスク全体の背景・目的を要約して渡す):
4. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する - 検討する視点(分析レンズ)と、課題の目的・背景
allowed_tools: - 「この視点で分析し、結論と根拠を出す。WebSearch / WebFetch / 一次情報で
- Read 裏を取り、モデルの内部知識だけで書かない」指示
- Grep - 「結論・根拠・メリット/デメリット・リスクの構成で
- Glob output/research/perspective-{連番}.md に Write せよ」指示
- SpawnSubTask - 「完了したら 3〜5 行の要約だけを返せ。本文は返さずファイルに書け」
rules: - 「あなたは末端の担当。delegate / SpawnSubTask は呼ぶな」
- condition: "全てのサブタスクを SpawnSubTask で登録し終えた" 各視点が異なる分析レンズを持つよう明確に指定する。
next: WAIT_SUBTASKS 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
- condition: 全てのサブタスクを登録し終えたSpawnSubTask不可の場合は自分で分析を完了した 3. Glob で output/research/*.md の件数を確認する。
next: aggregate 4. 各サブの要約とファイルを基に推奨方針を output/recommendation.md に Write する:
default_next: aggregate
- name: aggregate
edit: true
persona: analyst
instruction: |
各サブタスクの検討結果を統合し、推奨方針をまとめてください。
1. subtasks/ ディレクトリを確認するGlob: subtasks/*/output/**
2. 各サブタスクの分析結果を読み込む
3. 共通点・相違点・トレードオフを整理する
4. 総合的な推奨方針を output/recommendation.md に作成する
- 各視点からの主要な発見 - 各視点からの主要な発見
- トレードオフの整理 - 共通点・相違点・トレードオフの整理
- 推奨アプローチとその根拠 - 推奨アプローチとその根拠
- リスクと緩和策 - リスクと緩和策
5. 完了したら verify に遷移する - 各視点の詳細へ相対リンク [perspective-{連番}](./research/perspective-{連番}.md) で繋ぐ
5. output/recommendation.md を書き終えたら verify へ遷移する
allowed_tools: 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__*']
- Read default_next: verify
- Glob
- Grep
- Write
- Edit
- 'mcp__*'
rules: rules:
- condition: "output/recommendation.md に推奨方針をまとめた" - condition: "output/recommendation.md に推奨方針をまとめた"
next: verify next: verify
default_next: verify
- name: verify - name: verify
edit: false edit: false
@ -77,9 +60,9 @@ movements:
確認手順: 確認手順:
1. まず Glob で output/ 内のファイル一覧を確認する 1. まず Glob で output/ 内のファイル一覧を確認する
2. output/recommendation.md がなければ「修正が必要」と判断し aggregate に差し戻す 2. output/recommendation.md がなければ「修正が必要」と判断し delegate_research に差し戻す
3. ファイルがあれば Read で内容を確認し、各視点の分析が含まれているか・推奨方針が論理的かをチェックする 3. ファイルがあれば Read で内容を確認し、各視点の分析が含まれているか・推奨方針が論理的かをチェックする
4. 不足や誤りがあれば、`transition({next_step: "aggregate", summary: ...})` で差し戻す。summary は次の形式で書く: 4. 不足や誤りがあれば、`transition({next_step: "delegate_research", summary: ...})` で差し戻す。summary は次の形式で書く:
[判定] needs_fix [判定] needs_fix
## 問題点 ## 問題点
- [ファイル名:行番号または項目名] 何が問題か - [ファイル名:行番号または項目名] 何が問題か
@ -88,7 +71,7 @@ movements:
## 合格基準 ## 合格基準
- 再レビューで何を確認するか - 再レビューで何を確認するか
## 次にやること ## 次にやること
- aggregate で最初に着手すべき具体的な修正 - delegate_research で最初に着手すべき具体的な修正
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める 5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
## チェックシート確認 ## チェックシート確認
@ -105,7 +88,7 @@ movements:
## 終了方法のまとめ ## 終了方法のまとめ
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "aggregate", summary: "差し戻し指摘"})` (上記形式で) - 修正必要: `transition({next_step: "delegate_research", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep] allowed_tools: [Read, Glob, Grep]
# default_next is the engine-internal fallback (context overflow / ASK # default_next is the engine-internal fallback (context overflow / ASK
@ -113,4 +96,4 @@ movements:
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: output/ にファイルがない、または内容に不足がある - condition: output/ にファイルがない、または内容に不足がある
next: aggregate next: delegate_research

View File

@ -8,48 +8,36 @@ max_movements: 999
initial_movement: execute initial_movement: execute
movements: movements:
- name: decompose - name: delegate_research
edit: false edit: true
persona: orchestrator persona: orchestrator
instruction: | instruction: |
入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。 入力把握で立てた分割調査計画に従い、各テーマを delegate で1件ずつ直列に
深掘りさせ、最後に統合レポートにまとめる。重い調査は各サブに任せ、
自分の文脈は軽く保つ。
手順: 手順:
1. 入力把握で立てた計画を思い出す(ファイル読み込みは不要) 1. 入力把握で立てた調査テーマを思い出す2〜5 個程度)。各テーマに上から
2. 各テーマに対して SpawnSubTask を呼び出す2〜5 個程度) 1 始まりの連番を振る1, 2, 3 ...)。
- title: テーマを簡潔に(例:「A社の製品ラインアップ調査」 2. 各テーマについて delegate を1回ずつ呼ぶ1件ずつ順番に・直列
- instruction: 何を調べて output/result.md にどう書くかを具体的に記述 delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、
- piece: 調査系は "research-sub"、汎用作業は "general"(サブタスクからさらに分解しないこと) このタスク全体の背景・目的を要約して渡す):
3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する - 調べる/作業するテーマと目的・背景
- 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示
## instruction の書き方例 - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で
について調査し、output/result.md に以下を含めてまとめてください: output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは
- 概要と主要な特徴 output/images/ に保存し Markdown で埋め込め」指示
- メリット・デメリット - 「完了したら 3〜5 行の要約だけを返せ。本文は返さずファイルに書け」
- 具体的な数値・事例(可能な限り)」 - 「あなたは末端の担当。delegate / SpawnSubTask は呼ぶな」
allowed_tools: [SpawnSubTask] 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
default_next: aggregate 3. Glob で output/research/*.md の件数を確認する。
rules: 4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する:
- 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 に最終レポートを作成する
- 各サブタスクの主要な知見を統合(矛盾・重複は整理)
- 全体のまとめと結論を付ける - 全体のまとめと結論を付ける
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
5. output/report.md を書き終えたら verify へ遷移する 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 default_next: verify
rules: rules:
- condition: output/report.md に統合レポートを作成した - condition: output/report.md に統合レポートを作成した
@ -68,14 +56,16 @@ movements:
3. 不明点があれば WebSearch/WebFetch で調べる 3. 不明点があれば WebSearch/WebFetch で調べる
4. 「今日のニュース」「最新動向」など時刻依存の依頼は、必ず最初に WebSearch を実行する 4. 「今日のニュース」「最新動向」など時刻依存の依頼は、必ず最初に WebSearch を実行する
## 並列分解の判断 ## 分割調査の判断delegate_research
以下の場合は decompose を積極的に検討する: 以下の場合は delegate_research を積極的に検討する:
- 複数の独立した調査対象がある(例: 3社の比較調査、複数トピックのリサーチ - 複数の独立した調査対象がある(例: 3社の比較調査、複数トピックのリサーチ
- 各調査が互いに依存せず、結果を最後に統合すればよい - 各調査が互いに依存せず、結果を最後に統合すればよい
- 全体を 1 回の execute で処理すると context が溢れるリスクがある - 全体を 1 回の execute で処理すると context が溢れるリスクがある
delegate_research では各テーマを delegate に1件ずつ任せ、本文はファイルに書かせて
自分の文脈を軽く保つ。直列実行)
decompose を使わない場合: delegate_research を使わない場合:
- 単一テーマの作業ファイル編集、1 つの調査など) - 単一テーマの作業ファイル編集、1 つの調査など)
- 各ステップが前のステップの結果に依存する逐次的な作業 - 各ステップが前のステップの結果に依存する逐次的な作業
@ -118,14 +108,14 @@ movements:
## 終了 / 遷移方法 ## 終了 / 遷移方法
- **次の verify へ**: `transition({next_step: "verify"})` - **次の 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: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `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__*'] 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 default_next: verify
rules: rules:
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した - condition: 2つ以上の独立したテーマがあり、分割して delegate に任せるのが効率的と判断した
next: decompose next: delegate_research
- condition: output/ にファイルを書き出した - condition: output/ にファイルを書き出した
next: verify next: verify

View File

@ -30,7 +30,7 @@ movements:
- 各ステップで使うツール(`allowed_tools` - 各ステップで使うツール(`allowed_tools`
- 入力と出力の形式 - 入力と出力の形式
ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "SpawnSubTask" })`)。 ツールの詳細仕様は ReadToolDoc で確認できる(例: `ReadToolDoc({ name: "delegate" })`)。
### YAML 構造の制約 ### YAML 構造の制約
```yaml ```yaml

View File

@ -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

View File

@ -10,42 +10,35 @@ max_movements: 999
initial_movement: dig initial_movement: dig
movements: movements:
- name: decompose - name: delegate_research
edit: false edit: true
persona: orchestrator persona: orchestrator
instruction: | instruction: |
入力把握で決めた並列調査計画に従い、各テーマをサブタスクとして登録する。 dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
最後に統合レポートにまとめる。重い調査は各サブに任せ、自分の文脈は軽く保つ。
手順: 手順:
1. 入力把握で立てた調査テーマを思い出す(ファイル読み込みは不要) 1. dig で立てた調査テーマを思い出す2〜5 個程度)。各テーマに上から 1 始まりの
2. 各テーマに対して SpawnSubTask を呼び出す2〜5 個程度、piece は "research-sub" 連番を振る1, 2, 3 ...)。
3. 全サブタスクの登録が完了したら WAIT_SUBTASKS に遷移する 2. 各テーマについて delegate を1回ずつ呼ぶ1件ずつ順番に・直列
delegate の prompt には必ず次を自己完結で書く(サブは独立した文脈で動くため、
instruction には「何を調べて output/result.md にどう書くか」を具体的に記述する このタスク全体の背景・目的を要約して渡す):
(概要・主要な特徴・数値や事例・まとめと考察 など、構成を明示)。 - 調べるテーマと目的・背景
allowed_tools: [SpawnSubTask] - 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示
default_next: aggregate - 「概要・主要な特徴・数値や事例・まとめと考察 の構成で
rules: output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは
- condition: 全サブタスクを SpawnSubTask で登録し終えた output/images/ に保存し Markdown で埋め込め」指示
next: WAIT_SUBTASKS - 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
- condition: 全てのサブタスクを登録し終えたSpawnSubTask不可の場合は自分で調査を完了した - 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」
next: aggregate 各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
3. Glob で output/research/*.md の件数を確認する。
- name: aggregate 4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する:
edit: true
persona: analyst
instruction: |
各サブタスクの調査結果が subtasks/ ディレクトリに格納されている。
手順:
1. Glob で subtasks/*/result.md と subtasks/*/output/ を確認する
2. 各 result.md と追加成果物を Read で読み込む
3. 全結果を統合して output/report.md に最終レポートを作成する
- 各テーマの主要な知見を統合(矛盾・重複は整理) - 各テーマの主要な知見を統合(矛盾・重複は整理)
- 比較・対照が必要なら表形式で整理 - 比較・対照が必要なら表形式で整理
- 全体のまとめと考察を付ける - 全体のまとめと考察を付ける
4. output/report.md を書き終えたら verify へ遷移する - 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
allowed_tools: [Read, Glob, Grep, Write, Edit, 'mcp__*'] 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 default_next: verify
rules: rules:
- condition: output/report.md に統合レポートを作成した - condition: output/report.md に統合レポートを作成した
@ -63,14 +56,16 @@ movements:
3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする 3. 調査対象と目的を整理し、どこから情報を集めるか、何を分析するかを明確にする
4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする 4. 「今日のニュース」「最新動向」「直近」など時刻依存の調査依頼では、必ず最初のアクションを WebSearch にする
## 並列分解の判断 ## 分割調査の判断delegate_research
decompose を積極的に検討するケース: delegate_research を積極的に検討するケース:
- 複数の独立した調査対象がある(例: 3社の比較、複数技術の比較 - 複数の独立した調査対象がある(例: 3社の比較、複数技術の比較
- 各調査が互いに依存せず、結果を最後に統合すればよい - 各調査が互いに依存せず、結果を最後に統合すればよい
- 全体を 1 回の dig → analyze で処理すると context が溢れるリスクがある - 全体を 1 回の dig → analyze で処理すると context が溢れるリスクがある
delegate_research では各テーマを delegate に1件ずつ任せ、本文はファイルに書かせて
自分の文脈を軽く保つ。直列実行)
decompose を使わないケース: delegate_research を使わないケース:
- 単一テーマの調査 - 単一テーマの調査
- 各ステップが前のステップの結果に依存する逐次的な調査 - 各ステップが前のステップの結果に依存する逐次的な調査
@ -109,15 +104,15 @@ movements:
## 終了 / 遷移方法 ## 終了 / 遷移方法
- **次の analyze へ**: `transition({next_step: "analyze"})` - **次の analyze へ**: `transition({next_step: "analyze"})`
- **並列分解 → decompose へ**: `transition({next_step: "decompose"})` - **分割調査 → delegate_research へ**: `transition({next_step: "delegate_research"})`
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `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__*'] 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 default_next: analyze
rules: rules:
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した - condition: 2つ以上の独立した調査テーマがあり、分割して delegate に任せるのが効率的と判断した
next: decompose next: delegate_research
- condition: output/ に情報を書き出した - condition: output/ に情報を書き出した
next: analyze next: analyze
- condition: 追加調査が必要 - condition: 追加調査が必要

81
scripts/install-x-adapter.sh Executable file
View File

@ -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" <<EOF
#!/usr/bin/env bash
exec "$VENV/bin/python" "$PREFIX/x_adapter.py" "\$@"
EOF
chmod +x "$WRAPPER"
echo ""
echo "x-adapter ready: $("$WRAPPER" --version 2>/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"

View File

@ -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

View File

@ -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")

View File

@ -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 <query> -t <tab> --max N --yaml [--full-text] [--compact]
user-posts <username> --max N --yaml [...]
tweet <id|url> --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()

View File

@ -10,11 +10,12 @@ import type { BackendStatusRegistry, NodeStatus } from '../engine/backend-status
function makeApp(userId: string, repo: Repository, opts?: { function makeApp(userId: string, repo: Repository, opts?: {
registry?: BackendStatusRegistry | null; registry?: BackendStatusRegistry | null;
role?: 'admin' | 'user';
}): express.Application { }): express.Application {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user' }; (req as any).user = { id: userId, role: opts?.role ?? 'user' };
next(); next();
}); });
app.use( app.use(
@ -85,6 +86,21 @@ describe('Dashboard API', () => {
expect(serialized).not.toMatch(/instruction|"u1"|"seed"/); 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 () => { it('returns 401 when no req.user and authActive=true', async () => {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());

View File

@ -52,9 +52,17 @@ export function createDashboardApi(deps: DashboardApiDeps): Router {
next(); next();
}); });
r.get('/workers', async (_req, res) => { r.get('/workers', async (req, res) => {
try { 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 }); res.json({ workers });
} catch (err) { } catch (err) {
logger.error(`[dashboard-api] GET /workers failed err=${err}`); logger.error(`[dashboard-api] GET /workers failed err=${err}`);

View File

@ -158,6 +158,45 @@ describe('collectWorkerStatuses', () => {
expect(row.online).toBeUndefined(); 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 () => { it('marks online=false for direct workers when probe failed', async () => {
const fakeRegistry = { const fakeRegistry = {
getAll: () => [{ getAll: () => [{

View File

@ -43,6 +43,16 @@ export interface WorkerStatusRow {
* reported". * reported".
*/ */
backends?: WorkerStatusBackendRow[]; 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 * operator can see *which* backend behind a LiteLLM / AAO Gateway
* front is currently in use rather than just "the proxy is busy". * front is currently in use rather than just "the proxy is busy".
* *
* Privacy: returns idle/running booleans + slot counts only. Never job * Privacy: by default returns idle/running booleans + slot counts only
* ids, titles, or owners, since the panel is shown to all users in a * never job ids, titles, or owners, since the panel is shown to all users
* multi-tenant deployment. * 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( export async function collectWorkerStatuses(
repo: Repository, repo: Repository,
workers: WorkerDef[], workers: WorkerDef[],
registry: Pick<BackendStatusRegistry, 'getAll'> | null = null, registry: Pick<BackendStatusRegistry, 'getAll'> | null = null,
opts?: { includeOwners?: boolean },
): Promise<WorkerStatusRow[]> { ): Promise<WorkerStatusRow[]> {
// 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 // Build a workerId → NodeStatus[] map once per call so we don't
// O(N*M) the registry snapshot per worker. registry.getAll() copies // O(N*M) the registry snapshot per worker. registry.getAll() copies
// its internal cache, so calling it once is cheap. // its internal cache, so calling it once is cheap.
@ -84,6 +103,10 @@ export async function collectWorkerStatuses(
state: repo.isWorkerBusy(w.id) ? 'running' : 'idle', state: repo.isWorkerBusy(w.id) ? 'running' : 'idle',
proxy: isProxy, proxy: isProxy,
}; };
if (ownersByWorker) {
const occupants = ownersByWorker.get(w.id);
if (occupants && occupants.length > 0) row.occupants = occupants;
}
if (isProxy && registry) { if (isProxy && registry) {
// Filter to backend-source rows only — the registry also stores a // Filter to backend-source rows only — the registry also stores a
// self-row for the proxy worker itself (source='proxy', nodeId = // self-row for the proxy worker itself (source='proxy', nodeId =

View File

@ -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 のみ ownerA は非メンバー)
let onlyAOwnerId = ''; // A のみ ownerB は非メンバー)
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;
// スペース AbothOwner と 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 });
// スペース BbothOwner と 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<string, unknown>;
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');
});
});
});

View File

@ -12,6 +12,17 @@ import { registerSpaceAppShareRoutes } from './space-app-share-api.js';
import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js'; import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js';
import { viewerOf } from './space-viewer.js'; import { viewerOf } from './space-viewer.js';
import { logger } from '../logger.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 経由で参照する。 // cross-calendar-api.ts は parseTzOffset / monthBounds を space-api 経由で参照する。
// 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、 // 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、
@ -147,5 +158,86 @@ export function createSpaceApi(deps: SpaceApiDeps): Router {
// ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。 // ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。
registerSpaceToolPolicyRoutes(router, deps); registerSpaceToolPolicyRoutes(router, deps);
// ─── スペース間資格情報コピー ──────────────────────────────────────
// 両スペースの管理権canManageSpaceを持つユーザーのみ利用可能。
// 成功時に audit_log へ記録する。
/** 閲覧者が source・target 両スペースを管理できるかを確認する。 */
async function canManageBothSpaces(
viewer: Express.User,
sourceId: string,
targetId: string,
): Promise<boolean> {
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=<id>
// 元スペースの 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; return router;
} }

View File

@ -211,6 +211,7 @@ function makeValidConfig(): AppConfig {
subtasks: { subtasks: {
maxDepth: 2, maxDepth: 2,
maxPerParent: 10, maxPerParent: 10,
spawnStaggerMs: 1000,
}, },
safety: { safety: {
maxIterations: 200, maxIterations: 200,
@ -320,6 +321,32 @@ describe('validateConfig', () => {
expect(errors.some(e => e.includes('subtasks.maxDepth'))).toBe(false); 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', () => { it('invalid retry.maxAttempts (0) produces error', () => {
const config = makeValidConfig(); const config = makeValidConfig();
config.retry.maxAttempts = 0; config.retry.maxAttempts = 0;

View File

@ -13,6 +13,7 @@ export interface AskConfig {
export interface SubtasksConfig { export interface SubtasksConfig {
maxDepth: number; // default: 2 (0 = no decomposition) maxDepth: number; // default: 2 (0 = no decomposition)
maxPerParent: number; // default: 10 (max subtasks a single job can spawn) 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 { export interface ToolsConfig {
@ -555,6 +556,7 @@ const defaults: AppConfig = {
subtasks: { subtasks: {
maxDepth: 2, maxDepth: 2,
maxPerParent: 10, maxPerParent: 10,
spawnStaggerMs: 1000,
}, },
tools: { tools: {
searxngUrl: 'http://searxng:8080', searxngUrl: 'http://searxng:8080',
@ -902,6 +904,9 @@ export function validateConfig(config: AppConfig): string[] {
if (!Number.isInteger(config.subtasks.maxPerParent) || config.subtasks.maxPerParent < 1) { if (!Number.isInteger(config.subtasks.maxPerParent) || config.subtasks.maxPerParent < 1) {
errors.push('subtasks.maxPerParent must be a positive integer'); 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) { if (!Number.isInteger(config.retry.maxAttempts) || config.retry.maxAttempts <= 0) {
errors.push('retry.maxAttempts must be a positive integer'); errors.push('retry.maxAttempts must be a positive integer');

View File

@ -1793,3 +1793,71 @@ describe('Repository.createJobIfNoPending', () => {
} finally { repo.close(); } } 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<string> {
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<string> {
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(); }
});
});

View File

@ -2814,6 +2814,39 @@ export class Repository {
return !!row; return !!row;
} }
/**
* (status='running') worker_id
* (`task_kind`: 'agent' / 'reflection' )
* **admin ** / GPU GPU
* dashboard-workers privacy
*
* `user` `users.name`nullowner / no-auth 'local' owner_id 'unknown'
* `(user, kind)` 1 /
*
*/
listRunningJobOwnersByWorker(): Map<string, Array<{ user: string; kind: string }>> {
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<string, Array<{ user: string; kind: string }>>();
for (const r of rows) {
const user = (r.ownerName && r.ownerName.trim()) || r.ownerId || 'unknown';
const kind = r.taskKind || 'agent';
const list = byWorker.get(r.workerId);
if (list) {
if (!list.some((o) => o.user === user && o.kind === kind)) list.push({ user, kind });
} else {
byWorker.set(r.workerId, [{ user, kind }]);
}
}
return byWorker;
}
async updateJob(id: string, updates: Partial<Omit<Job, 'id' | 'createdAt'>>): Promise<void> { async updateJob(id: string, updates: Partial<Omit<Job, 'id' | 'createdAt'>>): Promise<void> {
const setClauses: string[] = ["updated_at = datetime('now')"]; const setClauses: string[] = ["updated_at = datetime('now')"];
const params: Record<string, unknown> = { id }; const params: Record<string, unknown> = { id };

View File

@ -67,6 +67,10 @@ export interface MovementResult {
lessons?: string | null; // このステップで得た教訓 lessons?: string | null; // このステップで得た教訓
waitReason?: string | null; // waiting_human の場合の待機理由(例: 'browser_login' waitReason?: string | null; // waiting_human の場合の待機理由(例: 'browser_login'
browserSessionId?: string | null; // InteractiveBrowse で確保したセッションID browserSessionId?: string | null; // InteractiveBrowse で確保したセッションID
// next='WAIT_SUBTASKS'WaitSubTask ツール / complete 時の自動停車)で、再開する
// movement 名を運ぶ。未指定なら piece-runner が movementDef.default_next に落とす。
// WaitSubTask は現在の movement 名を入れ、子完了後に同じステップへ再入させる。
resumeMovement?: string | null;
// next='ABORT' のときに、どの経路で abort したかを示す細分コード。 // next='ABORT' のときに、どの経路で abort したかを示す細分コード。
// piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら // piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら
// 'movement_abort'(後方互換)。 // 'movement_abort'(後方互換)。
@ -2426,6 +2430,21 @@ export async function executeMovement(
waitReason: 'tool_request', 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; regularToolsUsed += dispatch.regularToolsUsedDelta;
const pendingImages = dispatch.pendingImages; const pendingImages = dispatch.pendingImages;
@ -2471,6 +2490,29 @@ export async function executeMovement(
const winner = selectTerminalWinner(classified); const winner = selectTerminalWinner(classified);
if (winner.kind === 'native_winner') { 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( const result = buildMovementResultFromComplete(
winner.args, winner.args,
toolsUsed, toolsUsed,

View File

@ -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 // Traceability T-2 — handoff / delta / followup / context_action
// ============================================================ // ============================================================

View File

@ -386,6 +386,7 @@ export async function runPiece(
askCount?: number; askCount?: number;
maxAskPerJob?: number; maxAskPerJob?: number;
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
hasPendingSubtasks?: () => Promise<boolean>;
cancelCheck?: () => boolean; cancelCheck?: () => boolean;
abortController?: AbortController; abortController?: AbortController;
safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean }; 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 * as the gate for every movement. When absent, falls back to safe defaults
* (resolveWorkspaceTools({})). Resolved once per job by the worker. */ * (resolveWorkspaceTools({})). Resolved once per job by the worker. */
workspaceTools?: { allowedTools: string[]; editAllowed: boolean }; 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 /** Tool-request mechanism: true when this run can pause for inline approval
* (a reachable user local task, not a subtask/headless run). */ * (a reachable user local task, not a subtask/headless run). */
toolApprovalInteractive?: boolean; toolApprovalInteractive?: boolean;
@ -792,6 +799,7 @@ function prepareMovementContext(
callbacks: PieceRunCallbacks | undefined, callbacks: PieceRunCallbacks | undefined,
options?: { options?: {
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
hasPendingSubtasks?: () => Promise<boolean>;
searchFilter?: SearchFilterConfig; searchFilter?: SearchFilterConfig;
customPiecesDir?: string | string[]; customPiecesDir?: string | string[];
vlmEnabled?: boolean; vlmEnabled?: boolean;
@ -847,6 +855,13 @@ function prepareMovementContext(
* edit flag for this job's space. When provided, replaces movement.allowed_tools * edit flag for this job's space. When provided, replaces movement.allowed_tools
* and movementDef.edit as the gate for this movement. */ * and movementDef.edit as the gate for this movement. */
workspaceTools?: { allowedTools: string[]; editAllowed: boolean }; 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 /** Tool-request mechanism: true when this run can pause for inline approval
* (a reachable user local task, not a subtask/headless run). */ * (a reachable user local task, not a subtask/headless run). */
toolApprovalInteractive?: boolean; toolApprovalInteractive?: boolean;
@ -883,7 +898,9 @@ function prepareMovementContext(
// movement.allowed_tools. grantedTools (inline approvals) always union in. // movement.allowed_tools. grantedTools (inline approvals) always union in.
// META_TOOLS are added downstream by getToolDefs — not double-added here. // META_TOOLS are added downstream by getToolDefs — not double-added here.
allowedTools: effectiveAllowedTools, allowedTools: effectiveAllowedTools,
allowedSshConnections: movementDef.allowed_ssh_connections, allowedSshConnections: options?.workspaceSshConnections !== undefined
? options.workspaceSshConnections
: movementDef.allowed_ssh_connections,
rules: movementDef.rules, rules: movementDef.rules,
defaultNext: movementDef.default_next, defaultNext: movementDef.default_next,
}; };
@ -899,7 +916,12 @@ function prepareMovementContext(
bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork, bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork,
skillCatalog: options?.skillCatalog, skillCatalog: options?.skillCatalog,
userRole: options?.userRole, 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, pieceName: piece.name,
// Tool-request mechanism: movement identity + effective tool set for // Tool-request mechanism: movement identity + effective tool set for
// RequestTool classification, and a recorder wrapped to inject piece + // 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). // resolution. Phase 1 no-op (no consumer reads ctx.spaceId yet).
spaceId: options?.spaceId, spaceId: options?.spaceId,
spawnSubTask: options?.spawnSubTask, spawnSubTask: options?.spawnSubTask,
hasPendingSubtasks: options?.hasPendingSubtasks,
missionBrief: options?.missionBrief, missionBrief: options?.missionBrief,
taskId: options?.taskId, taskId: options?.taskId,
// No-auth jobs have no owner (ownerId/userId null). Resolve a single 'local' // No-auth jobs have no owner (ownerId/userId null). Resolve a single 'local'
@ -957,6 +980,15 @@ function prepareMovementContext(
movementName: movementDef.name, 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')); const checklistContext = buildChecklistContext(options?.runtimeDir ?? join(workspacePath, 'logs'));
if (checklistContext) { if (checklistContext) {
enrichedInstruction += '\n\n' + checklistContext; enrichedInstruction += '\n\n' + checklistContext;
@ -969,7 +1001,7 @@ function prepareMovementContext(
piece.movements.some((m) => m.allowed_tools.includes('SpawnSubTask')) || piece.movements.some((m) => m.allowed_tools.includes('SpawnSubTask')) ||
(piece.shared_tools?.includes('SpawnSubTask') ?? false); (piece.shared_tools?.includes('SpawnSubTask') ?? false);
if (!options?.spawnSubTask && pieceUsesSpawn) { if (!options?.spawnSubTask && pieceUsesSpawn) {
enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できません。decompose ではなく dig を選択してください。'; enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できませんこれ以上の分解は不可。delegate を使うか、自分で作業を完了させてください。';
} }
if (lessonsAccumulator.length > 0) { if (lessonsAccumulator.length > 0) {
enrichedInstruction += '\n\n' + buildLessonsContext(lessonsAccumulator); enrichedInstruction += '\n\n' + buildLessonsContext(lessonsAccumulator);
@ -1189,14 +1221,19 @@ function mapMovementResult(
} }
if (result.next === 'WAIT_SUBTASKS') { 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 { return {
kind: 'done', kind: 'done',
result: { result: {
status: 'waiting_subtasks', status: 'waiting_subtasks',
finalOutput: result.output, finalOutput: result.output,
movementHistory, movementHistory,
resumeMovement: movementDef.default_next ?? null, resumeMovement,
abortReason: null, abortReason: null,
contextActions, contextActions,
}, },
@ -1412,6 +1449,34 @@ export function buildFollowupNotice(workspacePath: string): string {
].join('\n'); ].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/ * workspace/logs/checklists/
* movement system prompt * movement system prompt

View File

@ -115,6 +115,11 @@ export interface ToolContext {
mcpQuotaState?: { files: number; bytes: number }; mcpQuotaState?: { files: number; bytes: number };
runIsolatedLlm?: (messages: Message[]) => Promise<string>; runIsolatedLlm?: (messages: Message[]) => Promise<string>;
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>; spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
/**
* WaitSubTask: このジョブに未完了 terminal
* worker getSubJobs undefined
*/
hasPendingSubtasks?: () => Promise<boolean>;
/** /**
* B: 直列delegate * B: 直列delegate
* *
@ -212,6 +217,12 @@ export interface ToolContext {
* for inline approval of the named tool. Read + reset by agent-loop. * for inline approval of the named tool. Read + reset by agent-loop.
*/ */
pendingToolApproval?: { toolName: string } | null; 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. */ /** Per-task option: when true, MCP tools are not loaded/dispatched. */
mcpDisabled?: boolean; mcpDisabled?: boolean;
/** Per-task option: when true, skill index is not injected into the system prompt. */ /** Per-task option: when true, skill index is not injected into the system prompt. */

View File

@ -100,6 +100,8 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
appbridge: 'workspace-apps', appbridge: 'workspace-apps',
// TestWorkspaceApp (testworkspaceapp.md) // TestWorkspaceApp (testworkspaceapp.md)
testworkspaceapp: 'testworkspaceapp', testworkspaceapp: 'testworkspaceapp',
// orchestration.ts: WaitSubTask は spawnsubtask.md にまとめる
waitsubtask: 'spawnsubtask',
}; };
const READ_TOOL_DOC_DEF: ToolDef = { const READ_TOOL_DOC_DEF: ToolDef = {

View File

@ -151,3 +151,39 @@ describe('delegate tool', () => {
expect(res?.isError).toBe(true); 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<ToolContext>);
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<ToolContext>);
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<ToolContext>);
const res = await executeTool('WaitSubTask', {}, ctx);
expect(hasPendingSubtasks).toHaveBeenCalledTimes(1);
expect(res?.isError).toBe(false);
expect(ctx.pendingSubtaskWait).toBe(true);
});
});

View File

@ -37,13 +37,25 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
}, },
piece: { piece: {
type: 'string', type: 'string',
description: '使用するピース名general, research, brainstorming, orchestrated, data-process, office-process。デフォルトは general。', description: '使用するピース名general, research, brainstorming, data-process, office-process。デフォルトは general。',
}, },
}, },
required: ['title', 'instruction'], 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( export async function executeTool(
@ -51,7 +63,28 @@ export async function executeTool(
input: Record<string, unknown>, input: Record<string, unknown>,
ctx: ToolContext, ctx: ToolContext,
): Promise<ToolResult | null> { ): Promise<ToolResult | null> {
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 (name === 'delegate') {
if (!ctx.runDelegate) { if (!ctx.runDelegate) {

View File

@ -70,6 +70,14 @@ describe('tool-categories', () => {
expect(SENSITIVE_TOOLS.has('Read')).toBe(false); 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', () => { it('exports META_TOOLS with expected entries', () => {
expect(META_TOOLS).toBeInstanceOf(Set); expect(META_TOOLS).toBeInstanceOf(Set);
expect(META_TOOLS.has('ReadToolDoc')).toBe(true); expect(META_TOOLS.has('ReadToolDoc')).toBe(true);

View File

@ -56,8 +56,17 @@ export const SENSITIVE_CATEGORIES: ReadonlySet<string> = new Set(['ssh', 'browse
/** /**
* Individual tools that require elevated scrutiny regardless of their * Individual tools that require elevated scrutiny regardless of their
* category (e.g. Bash even though its category is 'core'). * 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<string> = new Set(['Bash']); export const SENSITIVE_TOOLS: ReadonlySet<string> = new Set(['Bash', 'SpawnSubTask']);
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Module specs — canonical category → module specifier mapping // Module specs — canonical category → module specifier mapping

View File

@ -53,6 +53,14 @@ describe('sanitizeQuery', () => {
it('preserves public IPs', () => { it('preserves public IPs', () => {
expect(sanitizeQuery('query 8.8.8.8 dns', {})).toBe('query 8.8.8.8 dns'); 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', () => { describe('web tools', () => {
@ -148,6 +156,17 @@ describe('web tools', () => {
expect(fs.existsSync(path.join(workspacePath, 'logs', 'webfetch-screenshots'))).toBe(false); 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 () => { it('records invalid URL attempts before fetch', async () => {
workspacePath = makeWorkspace(); workspacePath = makeWorkspace();

View File

@ -108,7 +108,16 @@ const INTERNAL_DOMAIN_PATTERN = /\b[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.(?:
* *
* @returns null * @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 = { const autoBlock = {
privateIp: config.autoBlock?.privateIp !== false, privateIp: config.autoBlock?.privateIp !== false,
internalDomain: config.autoBlock?.internalDomain !== false, internalDomain: config.autoBlock?.internalDomain !== false,
@ -630,10 +639,15 @@ async function executeWebSearch(
input: Record<string, unknown>, input: Record<string, unknown>,
ctx: ToolContext, ctx: ToolContext,
): Promise<ToolResult> { ): Promise<ToolResult> {
const rawQuery = input['query'] as string; const rawQuery = input['query'];
const rawLimit = typeof input['limit'] === 'number' ? input['limit'] : 5; const rawLimit = typeof input['limit'] === 'number' ? input['limit'] : 5;
const limit = Math.min(Math.max(1, rawLimit), 20); 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 filterConfig = ctx.searchFilter ?? ctx.toolsConfig?.searchFilter ?? {};
const sanitized = sanitizeQuery(rawQuery, filterConfig); const sanitized = sanitizeQuery(rawQuery, filterConfig);

View File

@ -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> = {}): 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 });
}
});
});

View File

@ -12,6 +12,9 @@ describe('workspace tool policy', () => {
expect(r.allowedTools).toContain('WebSearch'); expect(r.allowedTools).toContain('WebSearch');
expect(r.allowedTools).toContain('ReadToolDoc'); // META always expect(r.allowedTools).toContain('ReadToolDoc'); // META always
expect(r.allowedTools).not.toContain('Bash'); // sensitive tool 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('BrowseWeb'); // sensitive category
expect(r.allowedTools).not.toContain('SshExec'); expect(r.allowedTools).not.toContain('SshExec');
expect(r.editAllowed).toBe(true); // Write/Edit are safe-on 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).toContain('BrowseWeb');
expect(r.allowedTools).not.toContain('SshExec'); // ssh not opted in 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 () => { it('disabledSafe removes a safe category', async () => {
const r = await resolveWorkspaceTools({ disabledSafe: ['web'] }); const r = await resolveWorkspaceTools({ disabledSafe: ['web'] });
expect(r.allowedTools).not.toContain('WebSearch'); expect(r.allowedTools).not.toContain('WebSearch');

View File

@ -52,7 +52,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
// browser.ts // browser.ts
'BrowseWeb', 'BrowseWithSession', 'InteractiveBrowse', 'BrowseWeb', 'BrowseWithSession', 'InteractiveBrowse',
// orchestration.ts // orchestration.ts
'SpawnSubTask', 'SpawnSubTask', 'WaitSubTask',
// x.ts // x.ts
'XFetchCardMedia', 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts', 'XFetchCardMedia', 'XPostDetail', 'XSearch', 'XTimeline', 'XUserPosts',
// maps.ts // maps.ts

View File

@ -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); // 増えていない
});
});

View File

@ -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;
})();
}

View File

@ -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);
});
});

View File

@ -25,6 +25,7 @@ import { pickIdlerIndex } from './worker/idle-routing.js';
import { jobEventBus } from './bridge/job-events.js'; import { jobEventBus } from './bridge/job-events.js';
import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js'; import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js';
import { parseToolPolicy, resolveWorkspaceTools } from './engine/workspace-tool-policy.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_HANDOFF_MAX_LENGTH = 8_000;
const RETRY_DIAGNOSTICS_PREVIEW_LENGTH = 1_200; const RETRY_DIAGNOSTICS_PREVIEW_LENGTH = 1_200;
@ -168,6 +169,34 @@ async function resolveSessionTaskId(
return { taskId: undefined, userId: job.ownerId ?? undefined }; 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 { function buildUiMetadataBlock(job: Job): string {
return [ 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<void> {
const step = 100;
let waited = 0;
while (waited < ms) {
if (cancelCheck?.()) return;
const chunk = Math.min(step, ms - waited);
await new Promise<void>((resolve) => setTimeout(resolve, chunk));
waited += chunk;
}
}
export async function maybeEnqueueReflection( export async function maybeEnqueueReflection(
repo: Repository, repo: Repository,
job: Job, job: Job,
@ -1375,11 +1423,11 @@ export class Worker {
subtaskDepth: job.subtaskDepth + 1, subtaskDepth: job.subtaskDepth + 1,
maxAttempts: 2, maxAttempts: 2,
role: job.requiredRole, role: job.requiredRole,
ownerId: job.ownerId, // 親ジョブから所有権・可視性・スペース・ブラウザセッションのバインドを
visibility: job.visibility, // 継承するspec §5.7 のスペース継承 + 保存セッションの引き継ぎ)。
visibilityScopeOrgId: job.visibilityScopeOrgId, // browserSessionProfileId が無いとサブタスク内の BrowseWeb が
// Spaces foundation (spec §5.7): inherit the parent job's space. // 保存セッション (ログイン Cookie) を使えず未ログインでアクセスする。
spaceId: job.spaceId ?? null, ...inheritJobContext(job),
// 計画5 (Opus P1): サブタスクは isolated worktree で実行され共有 // 計画5 (Opus P1): サブタスクは isolated worktree で実行され共有
// されないため runtime_dir はネストしないreporter は // されないため runtime_dir はネストしないreporter は
// subtaskWorkspace/logs にフォールバックし reader と一致する)。 // subtaskWorkspace/logs にフォールバックし reader と一致する)。
@ -1387,9 +1435,23 @@ export class Worker {
}); });
await this.repo.updateJob(subJob.id, { worktreePath: subtaskWorkspace }); await this.repo.updateJob(subJob.id, { worktreePath: subtaskWorkspace });
logger.info(`[worker:${this.workerId}] spawned sub-task #${subtaskIndex} depth=${job.subtaskDepth + 1} job=${subJob.id}`); 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 }; return { jobId: subJob.id, subtaskIndex, workspacePath: subtaskWorkspace };
} }
: undefined, : 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<boolean> => {
const subs = await this.repo.getSubJobs(jobId);
return subs.some((s) => !SUBTASK_DB_TERMINAL.includes(s.status));
},
}; };
const callbacks = this.buildPieceCallbacks( const callbacks = this.buildPieceCallbacks(
@ -1634,14 +1696,42 @@ export class Worker {
// above by resolveToolSpaceId). Fail-closed: any error → safe defaults // above by resolveToolSpaceId). Fail-closed: any error → safe defaults
// (sensitive tools off). Never throws — a policy error must not crash a job. // (sensitive tools off). Never throws — a policy error must not crash a job.
let workspaceTools: { allowedTools: string[]; editAllowed: boolean }; let workspaceTools: { allowedTools: string[]; editAllowed: boolean };
let parsedPolicy: import('./engine/workspace-tool-policy.js').WorkspaceToolPolicy;
try { try {
const policyJson = spaceId != null ? this.repo.getSpaceToolPolicy(spaceId) : null; const policyJson = spaceId != null ? this.repo.getSpaceToolPolicy(spaceId) : null;
workspaceTools = await resolveWorkspaceTools(parseToolPolicy(policyJson)); parsedPolicy = parseToolPolicy(policyJson);
workspaceTools = await resolveWorkspaceTools(parsedPolicy);
} catch (err) { } catch (err) {
logger.warn(`[worker:${this.workerId}] job ${jobId} workspace tool policy resolution failed, using safe defaults: ${(err as Error).message}`); logger.warn(`[worker:${this.workerId}] job ${jobId} workspace tool policy resolution failed, using safe defaults: ${(err as Error).message}`);
parsedPolicy = {};
workspaceTools = await resolveWorkspaceTools({}); 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, { const result = await runPiece(piece, enrichedInstruction, llmClient, workspacePath, callbacks, toolsConfig, {
...pieceOptions, ...pieceOptions,
cancelCheck, cancelCheck,
@ -1688,6 +1778,9 @@ export class Worker {
// Workspace tool policy: pre-resolved once per job above (fail-closed). // Workspace tool policy: pre-resolved once per job above (fail-closed).
// Drives every movement's effective allowed tools and edit flag. // Drives every movement's effective allowed tools and edit flag.
workspaceTools, 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 // Pause for inline approval only when a user is reachable: a local task
// that is not itself a subtask (subtasks/headless runs auto-proceed). // that is not itself a subtask (subtasks/headless runs auto-proceed).
toolApprovalInteractive: isLocalTask && !isSubTask, toolApprovalInteractive: isLocalTask && !isSubTask,
@ -2143,11 +2236,8 @@ export class Worker {
subtaskDepth: job.subtaskDepth, subtaskDepth: job.subtaskDepth,
maxAttempts: 2, maxAttempts: 2,
role: job.requiredRole, role: job.requiredRole,
ownerId: job.ownerId, // ASK 再投入でも所有権・可視性・スペース・ブラウザセッションのバインドを維持する。
visibility: job.visibility, ...inheritJobContext(job),
visibilityScopeOrgId: job.visibilityScopeOrgId,
// Spaces foundation: preserve the job's space across the ASK re-enqueue.
spaceId: job.spaceId ?? null,
}); });
await this.repo.updateJob(newJob.id, { worktreePath: workspacePath }); 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 }); 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', status: 'waiting_subtasks',
resumeMovement: result.resumeMovement ?? null, 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', { await this.repo.addAuditLog(jobId, 'job_waiting_subtasks', 'worker', {
resumeMovement: result.resumeMovement, resumeMovement: result.resumeMovement,
requeuedAtPark,
}); });
} }
} else if (result.status === 'cancelled') { } else if (result.status === 'cancelled') {
logger.info(`[worker:${this.workerId}] job ${jobId} 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); await reporter.reportFinalResult('cancelled', result.finalOutput);
} else { } 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); const retryDisposition = await this.scheduleRetryOrFail(job, result.finalOutput, workspacePath, result.abortReason ?? null);
if (retryDisposition !== 'requeued_unhealthy') { if (retryDisposition !== 'requeued_unhealthy') {
await reporter.reportFinalResult(result.status, result.finalOutput); 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<void> {
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 { private resolveModel(piece: PieceDef): string | undefined {
if (piece.model) { if (piece.model) {
if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) { if (this.availableModels.size === 0 || this.availableModels.has(piece.model)) {

View File

@ -1727,6 +1727,13 @@ export interface WorkerStatusRow {
online?: boolean; online?: boolean;
/** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */ /** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
backends?: WorkerStatusBackendRow[]; 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<WorkerStatusRow[]> { export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {

View File

@ -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>): 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(<WorkerStatusWidget />);
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(<WorkerStatusWidget />);
expect(screen.queryByTestId('worker-users-w1')).toBeNull();
});
});

View File

@ -10,6 +10,7 @@
import '../../../test/dom-setup'; import '../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react'; import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers'; import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n'; import '../../../i18n';
import type { LocalTask, LatestReflectionForTask } from '../../../api'; import type { LocalTask, LatestReflectionForTask } from '../../../api';
@ -22,10 +23,12 @@ vi.mock('../../../api', async (importOriginal) => {
...actual, ...actual,
getLatestReflectionForTask: vi.fn().mockResolvedValue(null), getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
fetchDelegateRuns: vi.fn().mockResolvedValue([]), fetchDelegateRuns: vi.fn().mockResolvedValue([]),
putFeedback: vi.fn(),
}; };
}); });
const mockedReflection = vi.mocked(api.getLatestReflectionForTask); const mockedReflection = vi.mocked(api.getLatestReflectionForTask);
const mockedPutFeedback = vi.mocked(api.putFeedback);
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask { function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
return { return {
@ -93,4 +96,46 @@ describe('OverviewTab', () => {
renderWithProviders(<OverviewTab task={makeTask()} />); renderWithProviders(<OverviewTab task={makeTask()} />);
expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument(); 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(<OverviewTab task={makeTask({
latestJob: { id: 'j', status: 'succeeded' },
feedbackRating: 'good',
feedbackTags: ['出力の精度が高い'],
})} />);
// 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(<OverviewTab task={makeTask({
latestJob: { id: 'j', status: 'succeeded' },
feedbackRating: 'good',
feedbackTags: ['レガシータグ'],
})} />);
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(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'succeeded' } })} />);
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');
});
});
}); });

View File

@ -9,11 +9,35 @@ import { ContextUsageGauge } from '../ContextUsageGauge';
import { ReflectionBadge } from '../ReflectionBadge'; import { ReflectionBadge } from '../ReflectionBadge';
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly'; import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった']; // Feedback tags. `value` is the canonical string that gets persisted (and fed
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた']; // 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<string, string>([...GOOD_TAGS, ...BAD_TAGS].map(t => [t.value, t.key]));
function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) { function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail'); 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 qc = useQueryClient();
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed'; const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
const hasFeedback = !!task.feedbackRating; const hasFeedback = !!task.feedbackRating;
@ -49,7 +73,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
{task.feedbackTags && task.feedbackTags.length > 0 && ( {task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => ( {task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span> <span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
))} ))}
</div> </div>
)} )}
@ -92,7 +116,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
{task.feedbackTags && task.feedbackTags.length > 0 && ( {task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => ( {task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span> <span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
))} ))}
</div> </div>
)} )}
@ -130,15 +154,15 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{tags.map(tag => ( {tags.map(tag => (
<button <button
key={tag} key={tag.value}
onClick={() => toggleTag(tag)} onClick={() => toggleTag(tag.value)}
className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${ className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${
selectedTags.includes(tag) selectedTags.includes(tag.value)
? 'bg-accent-soft border-accent text-accent' ? 'bg-accent-soft border-accent text-accent'
: 'border-slate-200 text-slate-500 hover:border-slate-300' : 'border-slate-200 text-slate-500 hover:border-slate-300'
}`} }`}
> >
{tag} {t(tag.key)}
</button> </button>
))} ))}
</div> </div>

View File

@ -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<CandidatesResponse> {
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<TransferResult> {
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<string>('');
const [checked, setChecked] = useState<Set<string>>(new Set());
const [transferError, setTransferError] = useState<string | null>(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 (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Content
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
style={{ maxWidth: 'min(520px, 94vw)', maxHeight: '88dvh' }}
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{kindLabel}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</Dialog.Close>
</div>
{/* Source workspace selector */}
<div className="flex flex-col gap-1 mb-4">
<label className="text-xs font-semibold text-slate-600">
</label>
{spacesLoading ? (
<div className="text-xs text-slate-400"></div>
) : sourceOptions.length === 0 ? (
<div className="text-xs text-slate-400">
</div>
) : (
<select
value={sourceId}
onChange={e => handleSourceChange(e.target.value)}
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="">-- --</option>
{sourceOptions.map(s => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)}
</div>
{/* Candidates list */}
{sourceId && (
<div className="mb-4">
{candidatesLoading && (
<div className="text-xs text-slate-400 py-3"></div>
)}
{isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
</div>
)}
{candidatesError && !isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {(candidatesError as Error).message}
</div>
)}
{!candidatesLoading && !candidatesError && items.length === 0 && (
<div className="text-xs text-slate-400 py-3 text-center">
{kindLabel}
</div>
)}
{!candidatesLoading && !candidatesError && items.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-600 mb-2">{kindLabel}</div>
<ul className="divide-y divide-hairline border border-hairline rounded-md overflow-hidden">
{items.map(item => (
<li
key={item.id}
className={`flex items-start gap-3 px-3 py-2.5 ${
item.willSkip ? 'opacity-60 bg-slate-50' : 'bg-canvas'
}`}
>
<input
type="checkbox"
id={`candidate-${item.id}`}
checked={checked.has(item.id)}
onChange={() => 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"
/>
<label
htmlFor={`candidate-${item.id}`}
className={`flex-1 min-w-0 ${item.willSkip ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-slate-800">
{item.name}
</span>
{item.willSkip && (
<span
title={item.skipReason ?? 'すでに存在します'}
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 leading-none cursor-help"
>
</span>
)}
</div>
<div className="text-2xs text-slate-500 mt-0.5 font-mono truncate">
{item.detail}
</div>
</label>
</li>
))}
</ul>
{/* Select all / deselect */}
{items.some(i => !i.willSkip) && (
<div className="mt-1.5 flex gap-3">
<button
type="button"
onClick={() => setChecked(new Set(items.filter(i => !i.willSkip).map(i => i.id)))}
className="text-2xs text-accent hover:underline"
>
</button>
<button
type="button"
onClick={() => setChecked(new Set())}
className="text-2xs text-slate-500 hover:underline"
>
</button>
</div>
)}
</div>
)}
</div>
)}
{/* Transfer error */}
{transferError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {transferError}
</div>
)}
{/* Footer buttons */}
<div className="flex justify-end gap-2 pt-1">
<Dialog.Close asChild>
<button
type="button"
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
</button>
</Dialog.Close>
<button
type="button"
onClick={handleImport}
disabled={!canImport}
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
{transferMut.isPending
? '取り込み中…'
: `取り込む${checkedCount > 0 ? ` (${checkedCount})` : ''}`}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -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 { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces'; import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
@ -425,21 +425,39 @@ function SpaceChat({
const qc = useQueryClient(); const qc = useQueryClient();
const auth = useAuthState(); const auth = useAuthState();
const { data: allTasks } = useLocalTaskList(); const { data: allTasks } = useLocalTaskList();
const { data: spaces } = useSpaces();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter; const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val }); const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val }); const setSearchQuery = (val: string) => onFilterChange({ search: val });
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val }); const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
const setSortMode = (val: SortMode) => onFilterChange({ sort: 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 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 const tasks = userId != null && hasOthersTasks
? filterTasksByScope(spaceTasks, scope, userId) ? filterTasksByScope(spaceTasks, scope, userId)
: spaceTasks; : spaceTasks;

View File

@ -95,7 +95,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
space={s} space={s}
active={s.id === selectedId} active={s.id === selectedId}
onSelect={onSelect} onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s)} runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId} mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
/> />
))} ))}

View File

@ -28,6 +28,7 @@ const SENSITIVE_NOTE_KEYS: Record<string, string> = {
ssh: 'tools.sensitiveNote.ssh', ssh: 'tools.sensitiveNote.ssh',
browser: 'tools.sensitiveNote.browser', browser: 'tools.sensitiveNote.browser',
Bash: 'tools.sensitiveNote.Bash', Bash: 'tools.sensitiveNote.Bash',
SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask',
}; };
function errMsg(e: unknown): string { function errMsg(e: unknown): string {

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthState } from '../../App'; import { useAuthState } from '../../App';
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
interface ServerPublic { interface ServerPublic {
id: string; id: string;
@ -296,6 +297,7 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null); const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const invalidateAll = () => { const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] }); qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
@ -423,11 +425,22 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
return ( return (
<div className="h-full overflow-y-auto" data-testid={inSpace ? 'space-mcp-panel' : undefined}> <div className="h-full overflow-y-auto" data-testid={inSpace ? 'space-mcp-panel' : undefined}>
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8"> <div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<div> <div className="flex items-start justify-between gap-3">
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2> <div>
<p className="text-[13px] text-slate-500 leading-relaxed"> <h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
{t('mcp.intro')} <p className="text-[13px] text-slate-500 leading-relaxed">
</p> {t('mcp.intro')}
</p>
</div>
{inSpace && (
<button
type="button"
onClick={() => setImportDialogOpen(true)}
className="flex-shrink-0 px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors"
>
WSから取り込む
</button>
)}
</div> </div>
{isLoading && <div className="text-[13px] text-slate-400">{t('mcp.loading')}</div>} {isLoading && <div className="text-[13px] text-slate-400">{t('mcp.loading')}</div>}
@ -493,6 +506,21 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
)} )}
</div> </div>
</div> </div>
{inSpace && importDialogOpen && (
<ImportFromWorkspaceDialog
targetSpaceId={spaceId!}
kind="mcp"
open={importDialogOpen}
onClose={() => 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');
}}
/>
)}
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import type { SshConnection, TestResponse } from '../../lib/ssh-types';
import { SshConnectionForm } from './SshConnectionForm'; import { SshConnectionForm } from './SshConnectionForm';
import { SshHostKeyDialog } from './SshHostKeyDialog'; import { SshHostKeyDialog } from './SshHostKeyDialog';
import { SshPublicKeyDialog } from './SshPublicKeyDialog'; import { SshPublicKeyDialog } from './SshPublicKeyDialog';
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
interface ConnectionsResponse { interface ConnectionsResponse {
connections: SshConnection[]; connections: SshConnection[];
@ -143,6 +144,7 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
}); });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null); const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
const [pubKeyDialog, setPubKeyDialog] = useState<{ const [pubKeyDialog, setPubKeyDialog] = useState<{
@ -256,14 +258,25 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
{t('ssh.intro')} {t('ssh.intro')}
</p> </p>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" {spaceId && (
onClick={() => { setCreating(true); setEditingId(null); }} <button
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep" type="button"
disabled={creating} onClick={() => setImportDialogOpen(true)}
> className="px-3 h-7 text-xs font-semibold text-slate-600 border border-hairline rounded-md hover:bg-surface transition-colors"
{t('ssh.create')} >
</button> WSから取り込む
</button>
)}
<button
type="button"
onClick={() => { setCreating(true); setEditingId(null); }}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
disabled={creating}
>
{t('ssh.create')}
</button>
</div>
</div> </div>
{isLoading && <div className="text-xs text-slate-400">{t('common.loading')}</div>} {isLoading && <div className="text-xs text-slate-400">{t('common.loading')}</div>}
@ -358,6 +371,21 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
onClose={() => setPubKeyDialog(null)} onClose={() => setPubKeyDialog(null)}
/> />
)} )}
{spaceId && importDialogOpen && (
<ImportFromWorkspaceDialog
targetSpaceId={spaceId}
kind="ssh"
open={importDialogOpen}
onClose={() => 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');
}}
/>
)}
</div> </div>
); );
} }

View File

@ -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 — 最近追加した画面が英語表示に対応 ## 2026-06-25 — 最近追加した画面が英語表示に対応
表示言語を English に切り替えたとき、新しめの画面が日本語のまま残っていました。ワークスペース(一覧・詳細・メンバー・カレンダー・ツール設定・アプリ)、ファイル操作(ツールバー・並べ替え・移動ダイアログ・プレビュー)、新規作成時のワークスペース選択などを英語化し、言語設定に合わせて切り替わるようにしました。言語は **設定 → 環境設定** で選べます。 表示言語を English に切り替えたとき、新しめの画面が日本語のまま残っていました。ワークスペース(一覧・詳細・メンバー・カレンダー・ツール設定・アプリ)、ファイル操作(ツールバー・並べ替え・移動ダイアログ・プレビュー)、新規作成時のワークスペース選択などを英語化し、言語設定に合わせて切り替わるようにしました。言語は **設定 → 環境設定** で選べます。

View File

@ -68,6 +68,14 @@ MAESTRO に頼める代表的な仕事です。
「ユーザー」「CAPTCHA」タブは管理者にだけ表示されます。 「ユーザー」「CAPTCHA」タブは管理者にだけ表示されます。
### コマンドパレット⌘K / Ctrl+K
`⌘K`Windows / Linux は `Ctrl+K`)で **コマンドパレット** が開きます。画面を横断して、ワークスペースやタスクへのジャンプ・主要アクションの実行を、キーボードだけで検索して呼び出せます。入力で候補を絞り込み、上下キーで選んで Enter で実行します。マウスでタブを辿らずに目的の場所へ飛びたいときに便利です。
### 外観(ライト / ダーク)
画面右上のトグルで、外観を **ライト / ダーク / システム追従** に切り替えられます。選択はこの端末に記憶されます。
## はじめての一歩 ## はじめての一歩
1. **タスクを作る** — 依頼内容を入力して実行します。書き方のコツは [タスクを作って実行する](./02-tasks.md) を参照 1. **タスクを作る** — 依頼内容を入力して実行します。書き方のコツは [タスクを作って実行する](./02-tasks.md) を参照

View File

@ -27,6 +27,10 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
依頼が曖昧だと、エージェントが確認を返して `waiting_human` で一時停止することがあります。 依頼が曖昧だと、エージェントが確認を返して `waiting_human` で一時停止することがあります。
### プロンプトコーチ(依頼文の採点・改善)
作成ダイアログには、書いた依頼文をその場で評価してくれる **プロンプトコーチ** があります。実行前に依頼文を採点し、何が足りないか(出力形式・観点・制約の不足など)を指摘したうえで、改善した文案を提示します。提案をそのまま入力欄に反映できるので、曖昧な依頼で `waiting_human` に止まる前に、依頼の質を上げられます。入力欄の近くに出る一般的な書き方のヒントTipsとは別の、あなたの依頼文に対する個別フィードバックです。
### 添付ファイル ### 添付ファイル
ダイアログのドロップゾーンにファイルをドラッグ&ドロップ、またはクリックで選択して添付できます。添付したファイルはワークスペースの `input/` に保存され、エージェントが読み込めます。依頼文で「input のファイルを読んで」と明示すると確実です。 ダイアログのドロップゾーンにファイルをドラッグ&ドロップ、またはクリックで選択して添付できます。添付したファイルはワークスペースの `input/` に保存され、エージェントが読み込めます。依頼文で「input のファイルを読んで」と明示すると確実です。

View File

@ -24,6 +24,10 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。 チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。
### コンテキスト残量ゲージ
概要タブと入力欄の上に、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が出ます。上限に近づくと色が変わり、限界が近いことが分かります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。
## 実行中に指示を追加する(割り込み / interjection ## 実行中に指示を追加する(割り込み / interjection
エージェントが動いている最中でも、メッセージを送って指示を追加できます。これを **割り込みinterjection** と呼びます。 エージェントが動いている最中でも、メッセージを送って指示を追加できます。これを **割り込みinterjection** と呼びます。
@ -50,6 +54,8 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
このため、関連する作業は新しいタスクを作り直すより、同じチャットで続けて頼むほうがスムーズです。やり取りの全文は各ワークスペースの `logs/transcript.jsonl` に記録されます。 このため、関連する作業は新しいタスクを作り直すより、同じチャットで続けて頼むほうがスムーズです。やり取りの全文は各ワークスペースの `logs/transcript.jsonl` に記録されます。
**別のタスクタイプpieceで続ける**こともできます。タスク詳細の「別の piece で続ける」ボタンから piece を選び直すと、これまでの会話・コメントを引き継いだまま、別のやり方(例: チャットで調べた内容をスライド生成 piece で資料化)で続行できます。
## 止める ## 止める
赤い **「停止」ボタン** を押すと、次のツール呼び出しのチェックポイントでエージェントが停止します。 赤い **「停止」ボタン** を押すと、次のツール呼び出しのチェックポイントでエージェントが停止します。

View File

@ -70,6 +70,17 @@ Markdown ファイルのプレビューを開くと、右上に **「PDF / 印
ヒント: ボタンが反応しないときは、ブラウザのポップアップブロックを解除してください(新規タブを開いて印刷するため)。 ヒント: ボタンが反応しないときは、ブラウザのポップアップブロックを解除してください(新規タブを開いて印刷するため)。
## チャットに出るリッチカード地図・X・YouTube・商品
エージェントが地図・SNS・動画・商品などを調べると、結果が**カード**としてチャットに表示されます。素の URL の羅列ではなく、見やすい形にまとまります。
- **地図 / 場所**: 検索した場所がカードで並ぶ
- **X (Twitter) 投稿**: 投稿の内容がカードで表示される
- **YouTube 動画**: 動画のサムネイル付きで一覧される
- **Amazon 商品**: 商品情報がカードで並ぶ
カードはクリックすると拡大表示モーダルで詳しく見られます。裏で動いているツールSearchPlaces / XSearch / SearchYouTube / SearchAmazon など)は [ツールリファレンス](./16-tools.md) を参照してください。
## ダウンロードする ## ダウンロードする
ファイルタブの各エントリからは、ファイルの実体をダウンロードできます。output に書き出された成果物レポート・CSV・画像などはここから手元に保存します。 ファイルタブの各エントリからは、ファイルの実体をダウンロードできます。output に書き出された成果物レポート・CSV・画像などはここから手元に保存します。

View File

@ -46,7 +46,9 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
| `sns-deep-sweep` | タイムラインや複数の投稿を選別し、1件ずつ個別の担当が深掘りして統合レポートにまとめます。投稿が多くても品質が均一になりやすいのが特長です。単一トピックの SNS の声を手早く知りたいときは「SNS 調査sns-research」を使ってください | | `sns-deep-sweep` | タイムラインや複数の投稿を選別し、1件ずつ個別の担当が深掘りして統合レポートにまとめます。投稿が多くても品質が均一になりやすいのが特長です。単一トピックの SNS の声を手早く知りたいときは「SNS 調査sns-research」を使ってください |
| `ssh-ops` | SSH 経由でリモートホストに単発オペレーションを実行health check / config push / log fetch | | `ssh-ops` | SSH 経由でリモートホストに単発オペレーションを実行health check / config push / log fetch |
| `ssh-console` | 人間と AI が 1 つの PTY セッションを共有する対話的 SSH コンソール。長時間作業や TUIvim / top / tmux / tail 等)向け | | `ssh-console` | 人間と AI が 1 つの PTY セッションを共有する対話的 SSH コンソール。長時間作業や TUIvim / 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) を参照。 `ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。

View File

@ -3,20 +3,43 @@ id: subtasks
title: サブ実行・並列実行 title: サブ実行・並列実行
category: advanced category: advanced
order: 100 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 の引数 ## SpawnSubTask の引数
@ -26,9 +49,7 @@ keywords: [サブタスク, SpawnSubTask, 並列, waiting_subtasks, research-sub
| instruction | 何を調査・実行し、どんな形式で `output/` に書くかの具体的な指示 | | instruction | 何を調査・実行し、どんな形式で `output/` に書くかの具体的な指示 |
| piece | 使用するピース(省略時は `general` | | piece | 使用するピース(省略時は `general` |
`piece` には `general` / `research` / `brainstorming` / `orchestrated` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。 `piece` には `general` / `research` / `brainstorming` / `data-process` / `office-process` などを指定できます。指定したピースが見つからない場合はエラーになります。
調査系のサブタスクには、専用ピース **research-sub**dig → analyze → verify の 3 ステップが使われます。research-sub はそれ以上のサブタスク分解を行わず、調査をその場で完結させます。
## サブ実行の確認 ## サブ実行の確認
@ -67,6 +88,8 @@ subtasks:
## TIP ## TIP
> サブタスクは「独立して並列実行できる」項目に向きます。前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。 > ふだんの分解は delegate直列で十分です。SpawnSubTask の並列化は、独立したテーマが複数あって壁時計時間を縮めたいときだけ、ツール設定で有効にして使います。
> 前の結果に依存する逐次処理は、1 つのタスク内の movement 遷移で扱う方が確実です(→「[ピースの仕組み](05-pieces.md)」)。
> サブタスクへの instruction には、成果物を `output/` にどう書くかまで具体的に指定すると、親がまとめやすくなります。 > サブタスクへの instruction には、成果物を `output/` にどう書くかまで具体的に指定すると、親がまとめやすくなります。

View File

@ -97,6 +97,15 @@ MCP ツール呼び出しの生レスポンスは、各ジョブのワークス
デバッグや監査の際にこれらを確認できます。 デバッグや監査の際にこれらを確認できます。
## 他のワークスペースから取り込む
同じ MAESTRO 内の別ワークスペースに登録済みの MCP サーバーを、まとめてコピーできます。**設定 → MCP** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。
- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること
- **スキップ**: 同じ id または同じ URL のサーバーがすでにある場合は自動でスキップされ、既存の設定は上書きされません
- **再認証が必要**: OAuth 方式のサーバーは per-user トークンを移せません。取り込み後に「連携」ボタンでブラウザ認証をやり直してください。API key 方式はそのまま使えます
- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません
## トラブルシューティング ## トラブルシューティング
- ツールが見えない: そのワークスペースに MCP サーバーが登録されていない、OAuth サーバーが未連携、ワークスペースのツール設定で MCP が無効、`MCP_ENCRYPTION_KEY` 未設定、またはタスク作成時に「MCP ツールを無効化」を ON にした、のいずれか - ツールが見えない: そのワークスペースに MCP サーバーが登録されていない、OAuth サーバーが未連携、ワークスペースのツール設定で MCP が無効、`MCP_ENCRYPTION_KEY` 未設定、またはタスク作成時に「MCP ツールを無効化」を ON にした、のいずれか

View File

@ -15,7 +15,12 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
| ssh-ops | 単発コマンド実行・ファイル転送 (health check / config push / log fetch) | `ssh-ops` | | ssh-ops | 単発コマンド実行・ファイル転送 (health check / config push / log fetch) | `ssh-ops` |
| ssh-console | 対話的な PTY シェル (vim / tmux / tail など長時間作業) | `ssh-console` | | 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: 単発実行とファイル転送 ## ssh-ops: 単発実行とファイル転送
@ -65,13 +70,23 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
## どう動かすか ## どう動かすか
1. admin が SSH を有効化し、接続と grant を整える 1. admin が `config.yaml` で SSH を有効化する
2. ユーザーが該当接続を登録 (個人接続の場合) して Test でホストキーを検証 2. ワークスペースの **設定 → ツール**`ssh` カテゴリをオンにする
3. `ssh-ops` または `ssh-console` を使うタスクを作成して実行 3. **設定 → SSH** で接続を登録し、Test でホストキーを検証する
4. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作 4. `ssh-ops` または `ssh-console` を使うタスクを作成して実行する
5. ssh-console の場合はタスク詳細の SSH タブで画面を確認・操作する
Piece の選び方や `allowed_tools` の考え方は [piece を使う・作る](./05-pieces.md) を参照してください。 Piece の選び方や `allowed_tools` の考え方は [piece を使う・作る](./05-pieces.md) を参照してください。
## 他のワークスペースから取り込む
同じ MAESTRO 内の別ワークスペースに登録済みの SSH 接続を、まとめてコピーできます。**設定 → SSH** の「他のワークスペースから取り込む」ボタンを押すと、自分が管理しているワークスペースの一覧が出るので、コピー元を選んで実行してください。
- **操作できる条件**: 取り込み元と移し先の両方でオーナー・管理者であること
- **スキップ**: 同じ label・同じ host/user の組み合わせがすでにある場合は自動でスキップされ、既存の接続は上書きされません
- **秘密鍵ごとコピー**: 秘密鍵は移し先ワークスペースの鍵で再暗号化されて保存されます。取り込み後すぐに使えますが、ホストキー検証TOFUが未完了の接続は Test を押して検証してください
- **同一サーバー内のみ**: 別環境(別インスタンス)へのエクスポートには対応していません
## よくあるエラー ## よくあるエラー
| エラーコード | 意味 | 対処 | | エラーコード | 意味 | 対処 |

View File

@ -76,6 +76,7 @@ Gateway / Worker いずれも Prometheus 互換の `/metrics` を公開できま
上部の **使用量** タブでは、LLM のトークン使用量を日次で確認できます。Gateway 経由と Direct各 worker が直接接続)の両方を合算し、お使いの環境のローカル日付で集計します。 上部の **使用量** タブでは、LLM のトークン使用量を日次で確認できます。Gateway 経由と Direct各 worker が直接接続)の両方を合算し、お使いの環境のローカル日付で集計します。
- **内訳の切り替え**: 経路・モデル・バックエンド・ユーザー・組織で分解して見られます - **内訳の切り替え**: 経路・モデル・バックエンド・ユーザー・組織で分解して見られます
- **時間帯別プロファイル**: 0〜23 時の時間帯ごとの使用傾向も確認でき、いつ負荷が集中するかが分かります
- **集計の対象**: 入力 / 出力トークンとリクエスト数。ユーザー別の一覧も出ます - **集計の対象**: 入力 / 出力トークンとリクエスト数。ユーザー別の一覧も出ます
- Virtual Keys のキー別課金パネルadmin の Gateway 設定内)とは **別集計** です。Usage タブは「実際に消費したトークン量の可視化」、課金パネルは「キーごとの予算管理」と捉えてください - Virtual Keys のキー別課金パネルadmin の Gateway 設定内)とは **別集計** です。Usage タブは「実際に消費したトークン量の可視化」、課金パネルは「キーごとの予算管理」と捉えてください

View File

@ -22,6 +22,7 @@ movement の開始時には、その movement で使えるツールの一覧と
|---|---|---| |---|---|---|
| ファイル / シェル | ワークスペースのファイル操作とコマンド実行 | Read / Write / Edit / Bash / Glob / Grep | | ファイル / シェル | ワークスペースのファイル操作とコマンド実行 | Read / Write / Edit / Bash / Glob / Grep |
| Web / 検索 | Web 検索・取得・ダウンロード | WebSearch / WebFetch / DownloadFile | | Web / 検索 | Web 検索・取得・ダウンロード | WebSearch / WebFetch / DownloadFile |
| 技術ドキュメント | Microsoft Learn の公式ドキュメントを検索・取得 | SearchMicrosoftLearn / FetchMicrosoftLearn |
| ブラウザ | 実ブラウザでのページ操作 | BrowseWeb | | ブラウザ | 実ブラウザでのページ操作 | BrowseWeb |
| Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx | | Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx |
| データ | SQLite データベース操作 | SQLite | | データ | SQLite データベース操作 | SQLite |
@ -32,7 +33,8 @@ movement の開始時には、その movement で使えるツールの一覧と
| SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend | | SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend |
| オーケストレーション | サブタスクの生成 | SpawnSubTask | | オーケストレーション | サブタスクの生成 | SpawnSubTask |
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections | | 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
| メディア | 文字起こし・動画字幕 | TranscribeAudio / GetYouTubeTranscript | | メディア | 文字起こし・動画検索・字幕取得 | TranscribeAudio / SearchYouTube / GetYouTubeTranscript |
| カレンダー | ワークスペースのカレンダーへ予定を登録・参照 | AddCalendarEvent / ListCalendarEvents |
| その他 | X(旧Twitter)検索・Amazon 検索など | XSearch / SearchAmazon | | その他 | X(旧Twitter)検索・Amazon 検索など | XSearch / SearchAmazon |
SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。 SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。
@ -46,7 +48,9 @@ SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照
- `RequestTool` — タスクに足りないツールの利用を申請する。チャット上でオーナーが承認すると、その場で使えるようになる - `RequestTool` — タスクに足りないツールの利用を申請する。チャット上でオーナーが承認すると、その場で使えるようになる
- `ReadUserMemory` / `UpdateUserMemory` — ワークスペースのメモリを読み書きする(→[メモリ](./12-memory.md) - `ReadUserMemory` / `UpdateUserMemory` — ワークスペースのメモリを読み書きする(→[メモリ](./12-memory.md)
- `CreateChecklist` / `CheckItem` / `GetChecklist` — タスク内の進捗チェックリスト - `CreateChecklist` / `CheckItem` / `GetChecklist` — タスク内の進捗チェックリスト
- `MissionUpdate` — 長時間タスクで、目標と現在地(進捗)をユーザーに途中報告するためのピン留めメモを更新する
- `GetMyOrchestratorState` — 自分が今どのワークスペース・タスクで動いているかを把握する - `GetMyOrchestratorState` — 自分が今どのワークスペース・タスクで動いているかを把握する
- `ReadAppDoc` / `ListAppDocs` — アプリ内のヘルプ・ドキュメントをエージェント自身が読む(`#help` のヘルプ応答などで使われる)
各ツールの function definition は 1 文に圧縮されているため、詳しい使い方はこの `ReadToolDoc` で取得する設計です。 各ツールの function definition は 1 文に圧縮されているため、詳しい使い方はこの `ReadToolDoc` で取得する設計です。

View File

@ -97,6 +97,16 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
|-----------|------| |-----------|------|
| HTTPS / TLS | `server.tls.*` — アプリ内 TLS 終端・証明書・HTTP→HTTPS リダイレクト・HSTS。詳細は下の「[HTTPS とリダイレクト](#https-とリダイレクト)」 | | HTTPS / TLS | `server.tls.*` — アプリ内 TLS 終端・証明書・HTTP→HTTPS リダイレクト・HSTS。詳細は下の「[HTTPS とリダイレクト](#https-とリダイレクト)」 |
## ブランディング(見た目のカスタマイズ)
System グループの **Branding** で、アプリ全体の見た目を組織向けに変えられます。
- **アプリ名**: タイトルや画面に出る名称
- **ロゴ / favicon**: 画像をアップロードして差し替え(ブラウザのタブに出るアイコンも変わる)
- **アクセント色**: ボタンやリンクなどの基調色
ここはアプリ**全体**の設定です。個々の案件ワークスペースに付ける「ブランド色」(一覧の色帯など)は別物で、各ワークスペースの設定で変えます(→[ワークスペースとメンバー](./21-workspaces.md))。
## Save & Apply の流れ ## Save & Apply の流れ
フォームの値を変更しても、すぐには反映されません。 フォームの値を変更しても、すぐには反映されません。

View File

@ -3,12 +3,16 @@ id: admin
title: ユーザー管理 / 安全性 title: ユーザー管理 / 安全性
category: admin category: admin
order: 190 order: 190
keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査] keywords: [ユーザー管理, 権限, admin, 安全性, bash, sandbox, 監査, セットアップ, 初回設定, ウィザード, CAPTCHA, noVNC]
--- ---
# ユーザー管理 / 安全性 (admin) # ユーザー管理 / 安全性 (admin)
このページは admin が行う運用作業をまとめます。ユーザーの承認・権限付与、公開範囲モデル、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。 このページは admin が行う運用作業をまとめます。初回セットアップ、ユーザーの承認・権限付与、公開範囲モデル、CAPTCHA の手動解決、そしてエージェントの安全制御 (Bash 実行・監査ログ) です。
## 初回セットアップウィザード
インストール直後(例: `docker compose up` の後)に初めて開くと、全画面の **セットアップウィザード** が表示されます。LLM の接続先・公開ポート・認証方式といった最低限の設定を順番に入力し、最後にまとめて適用します。ここを通すと、後から **設定** タブの各セクションで同じ項目を個別に調整できます。一度設定が済めばウィザードは出ません。
## ユーザー管理 ## ユーザー管理
@ -69,6 +73,10 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ
- 権限チェックは `buildVisibilityWhere(user, alias)` を一覧・取得クエリに差し込む形で一元化されている - 権限チェックは `buildVisibilityWhere(user, alias)` を一覧・取得クエリに差し込む形で一元化されている
- 親 → 子へ公開範囲がコピーされる (タスク → その spawn job、スケジュール → spawn job、親 job → 子 subtask) - 親 → 子へ公開範囲がコピーされる (タスク → その spawn job、スケジュール → spawn job、親 job → 子 subtask)
## CAPTCHA の手動解決CAPTCHA タブ)
エージェントがブラウザ操作中に CAPTCHA や追加認証に突き当たって止まることがあります。admin だけに見える **CAPTCHA** タブには、解決待ちのブラウザセッションが集まり、**noVNC のライブ画面**で人が手動で突破できます。画面は別ウィンドウピクチャー・イン・ピクチャーで前面固定にして、別作業をしながら対応することもできます。解決して取得したログイン状態cookie / storageは暗号化保存され、以降の自動ブラウズで再利用されます→[ブラウザ操作](./09-userfolder.md) のセッション)。
## 安全性: Bash 実行モード ## 安全性: Bash 実行モード
エージェントの `Bash` ツールの隔離は、`config.yaml` の **2 つの独立したキー**で制御します。どちらも設定 UI には出ない **config ファイル専用キー**です(危険なセキュリティ姿勢キーは UI トグルにせず、config 直編集 + 再起動を要求する方針)。 エージェントの `Bash` ツールの隔離は、`config.yaml` の **2 つの独立したキー**で制御します。どちらも設定 UI には出ない **config ファイル専用キー**です(危険なセキュリティ姿勢キーは UI トグルにせず、config 直編集 + 再起動を要求する方針)。
@ -110,6 +118,14 @@ Gitea を使わない場合は、**ローカル組織** を自分で作ってメ
> 本番は `bash_sandbox: always` + パッケージのプリベイクを推奨。bwrap が使えないホストでは `auto`hardened フォールバック)になるが、テナント間分離は弱くなる点に注意。 > 本番は `bash_sandbox: always` + パッケージのプリベイクを推奨。bwrap が使えないホストでは `auto`hardened フォールバック)になるが、テナント間分離は弱くなる点に注意。
## ワーカー / GPU の占有ユーザー
サイドパネルの **ワーカー** 一覧では、実行中の各ワーカーGPUに、いまそのワーカーを占有しているユーザー名`使用中: ...`)が表示される。複数ユーザーが同じワーカーで同時に実行している場合は連名で出る。「誰のタスクで GPU が埋まっているか」をこの画面だけで把握できる。
- **この表示は管理者にだけ見える**。一般ユーザーには稼働状況(実行中/待機)と空きスロットのみが見え、ユーザー名・ジョブ内容は出ない(マルチテナントのプライバシー保護)
- 表示名は登録ユーザーの名前。名前が取れない場合(旧データ等)はユーザー ID で代替表示する
- ユーザー名のとなりに、ジョブの**種別バッジ**`task_kind`)が付く。通常タスク(`agent`)にはバッジを付けず、`reflection`(学習)など通常以外のときだけタグを出すので、「学習で埋まっているだけ」を区別できる
## 監査ログ ## 監査ログ
- ジョブ実行に関する操作は `audit_log` テーブル (`action` / `actor` / `detail` / `created_at`) に記録される - ジョブ実行に関する操作は `audit_log` テーブル (`action` / `actor` / `detail` / `created_at`) に記録される

View File

@ -35,6 +35,8 @@ keywords: [ワークスペース, 個人ワークスペース, 案件ワーク
ワークスペースの「チャット」一覧には、検索・絞り込みバーがあります。キーワードタイトル・本文・Piece・作成者名での検索、状態実行中・待機・失敗などのタブ絞り込み、並び替え更新順・状態・タイトルが使えます。共有ワークスペースで「自分 / 他のメンバー」を切り替えている場合は、その範囲の中で絞り込みます。 ワークスペースの「チャット」一覧には、検索・絞り込みバーがあります。キーワードタイトル・本文・Piece・作成者名での検索、状態実行中・待機・失敗などのタブ絞り込み、並び替え更新順・状態・タイトルが使えます。共有ワークスペースで「自分 / 他のメンバー」を切り替えている場合は、その範囲の中で絞り込みます。
**「自分 / 他のメンバー」の切り替えについて**: 共有ワークスペースでは、メンバーが作成したチャットを「他のメンバー」で見られます。個人ワークスペースは作成者本人だけのものなので、通常この切り替えは出ません。例外として**管理者admin**は、個人ワークスペースの「他のメンバー」から他ユーザーの個人ワークスペースの内容を確認できます(全ユーザーの監視のため)。一般ユーザーには、共有されていない他人のチャットは見えません。
## ファイルを置く(エージェントの入力にする) ## ファイルを置く(エージェントの入力にする)
ワークスペースの **Files** にファイルを置くと、そのワークスペースのチャットで **エージェントの入力**になります。チャットを始めると、エージェントへ「ワークスペースの既存ファイル一覧」が自動で渡るので、`input/` フォルダに入れなくても置いたファイルは認識されます。新しく作ったワークスペースには `input/``output/` フォルダが最初から用意されています。ファイルがまだ 1 つもないときは、Files にその旨の案内が控えめに出ます。 ワークスペースの **Files** にファイルを置くと、そのワークスペースのチャットで **エージェントの入力**になります。チャットを始めると、エージェントへ「ワークスペースの既存ファイル一覧」が自動で渡るので、`input/` フォルダに入れなくても置いたファイルは認識されます。新しく作ったワークスペースには `input/``output/` フォルダが最初から用意されています。ファイルがまだ 1 つもないときは、Files にその旨の案内が控えめに出ます。

View File

@ -14,7 +14,8 @@
"fetchError": "Failed to fetch", "fetchError": "Failed to fetch",
"empty": "No workers configured", "empty": "No workers configured",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse" "collapse": "Collapse",
"inUse": "In use:"
}, },
"tabBar": { "tabBar": {
"expand": "Expand", "expand": "Expand",

View File

@ -24,7 +24,7 @@
"cancel": "Cancel", "starting": "Starting..." "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" }, "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" }, "title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
"mission": { "mission": {
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save", "pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",

View File

@ -373,7 +373,8 @@
"sensitiveNote": { "sensitiveNote": {
"ssh": "Allows connecting to remote shells. Commands can be executed on the target servers.", "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.", "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."
} }
} }
} }

View File

@ -14,7 +14,8 @@
"fetchError": "取得に失敗しました", "fetchError": "取得に失敗しました",
"empty": "Worker が設定されていません", "empty": "Worker が設定されていません",
"expand": "展開する", "expand": "展開する",
"collapse": "折りたたむ" "collapse": "折りたたむ",
"inUse": "使用中:"
}, },
"tabBar": { "tabBar": {
"expand": "展開", "expand": "展開",

View File

@ -24,7 +24,7 @@
"cancel": "キャンセル", "starting": "起動中..." "cancel": "キャンセル", "starting": "起動中..."
}, },
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" }, "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": "キャンセル" }, "title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
"mission": { "mission": {
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存", "pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",

View File

@ -373,7 +373,8 @@
"sensitiveNote": { "sensitiveNote": {
"ssh": "遠隔シェルへの接続を許可します。接続先サーバーでのコマンド実行が可能になります。", "ssh": "遠隔シェルへの接続を許可します。接続先サーバーでのコマンド実行が可能になります。",
"browser": "実ブラウザ操作を許可します。ウェブサイトへのアクセスやフォーム操作が実行されます。", "browser": "実ブラウザ操作を許可します。ウェブサイトへのアクセスやフォーム操作が実行されます。",
"Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。" "Bash": "任意コマンド実行を許可します。ホストシステムで任意のシェルコマンドが実行できます。",
"SpawnSubTask": "並列サブタスク(別ジョブ)への分解を許可します。子ごとにワーカーと GPU スロットを占有します。通常は直列の delegate で十分です。本当に並列実行が必要なときだけ有効化してください。"
} }
} }
} }

View File

@ -2,7 +2,13 @@ import { describe, it, expect } from 'vitest';
import { filterTasksForSpace, countRunningTasksForSpace } from './spaceTasks'; import { filterTasksForSpace, countRunningTasksForSpace } from './spaceTasks';
import type { LocalTask } from '../api'; 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 { return {
id, id,
title: `task ${id}`, title: `task ${id}`,
@ -15,6 +21,7 @@ function task(id: number, spaceId: string | null, updatedAt: string, status?: st
state: 'queued', state: 'queued',
workspacePath: null, workspacePath: null,
spaceId, spaceId,
ownerId,
createdAt: updatedAt, createdAt: updatedAt,
updatedAt, updatedAt,
...(status ? { latestJob: { id: `j${id}`, status } } : {}), ...(status ? { latestJob: { id: `j${id}`, status } } : {}),
@ -41,6 +48,29 @@ describe('filterTasksForSpace', () => {
expect(result.map(t => t.id)).toEqual([1, 2]); 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', () => { it('returns empty when no task matches', () => {
expect(filterTasksForSpace([task(1, 'sp-x', '2026-01-01')], 'sp-y')).toEqual([]); expect(filterTasksForSpace([task(1, 'sp-x', '2026-01-01')], 'sp-y')).toEqual([]);
}); });
@ -80,6 +110,19 @@ describe('countRunningTasksForSpace', () => {
expect(countRunningTasksForSpace(tasks, personalSpace)).toBe(2); 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', () => { it('ignores tasks with no latestJob', () => {
const tasks = [task(1, 'sp-a', '2026-01-01'), task(2, 'sp-a', '2026-01-02', 'running')]; const tasks = [task(1, 'sp-a', '2026-01-01'), task(2, 'sp-a', '2026-01-02', 'running')];
expect(countRunningTasksForSpace(tasks, caseSpace)).toBe(1); expect(countRunningTasksForSpace(tasks, caseSpace)).toBe(1);

View File

@ -6,15 +6,29 @@ import type { LocalTask } from '../api';
* `spaceId` updatedAt * `spaceId` updatedAt
* *
* spec §7: `space_id` null * 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( export function filterTasksForSpace(
tasks: LocalTask[], tasks: LocalTask[],
spaceId: string, spaceId: string,
isPersonalSpace = false, isPersonalSpace = false,
caseSpaceIds?: ReadonlySet<string>,
): LocalTask[] { ): LocalTask[] {
return tasks 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 ?? '')); .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
} }
@ -22,15 +36,23 @@ export function filterTasksForSpace(
* (latestJob.status === 'running') * (latestJob.status === 'running')
* null space_id (filterTasksForSpace ) * null space_id (filterTasksForSpace )
* API * API
*
* ****admin API
* (1=1) owner null
* `viewerId` owner
* (no-auth )
*/ */
export function countRunningTasksForSpace( export function countRunningTasksForSpace(
tasks: LocalTask[], tasks: LocalTask[],
space: { id: string; kind: 'personal' | 'case' }, space: { id: string; kind: 'personal' | 'case' },
viewerId?: string | null,
): number { ): number {
const isPersonal = space.kind === 'personal'; const isPersonal = space.kind === 'personal';
return tasks.filter( return tasks.filter((t) => {
t => if (t.latestJob?.status !== 'running') return false;
t.latestJob?.status === 'running' && const inSpace = t.spaceId === space.id || (isPersonal && t.spaceId == null);
(t.spaceId === space.id || (isPersonal && t.spaceId == null)), if (!inSpace) return false;
).length; if (isPersonal && viewerId != null && t.ownerId !== viewerId) return false;
return true;
}).length;
} }