Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3dc5529a | ||
|
|
a67c40d33c | ||
|
|
2044f0a2c4 | ||
|
|
63d34d7cf6 |
41
CHANGELOG.md
41
CHANGELOG.md
@ -6,6 +6,47 @@ follow semantic versioning.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- A compact Movement Map in chat shows the current step, completed work,
|
||||
retries, and user messages without taking over the conversation. Nodes can
|
||||
be used for navigation, while concise tooltips explain each step
|
||||
(2026-07-12).
|
||||
- Slack channel connections can be managed from Settings, including
|
||||
channel-to-workspace bindings and reliable mention ingestion and replies.
|
||||
Reminders and in-app notifications make scheduled and background work easier
|
||||
to follow (2026-07-12).
|
||||
- Chat now provides per-task model and reasoning-effort selection, plus prompt
|
||||
coaching before sending follow-up instructions (2026-07-11).
|
||||
- Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write
|
||||
date-stamped outputs (e.g. `output/report-2026-07-09.md`,
|
||||
`output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace
|
||||
accumulate instead of overwriting the previous report. The agent system
|
||||
prompt now always includes the current date/time (2026-07-09).
|
||||
- Draft autosave for the new-task dialog, chat composer, feedback comments,
|
||||
and continue instructions — text survives an accidental close or reload.
|
||||
Files can be attached by pasting (Ctrl+V) into the new-task dialog
|
||||
(2026-07-09).
|
||||
- Settings: a "Skill quotas" section plus editors for several previously
|
||||
config-file-only options (max stream time, subtask debounce, summary
|
||||
headroom, .msg size limit, TLS minimum version, worker health-check
|
||||
interval) (2026-07-09).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Model selection menus no longer get clipped by the chat viewport, and the
|
||||
“jump to latest” control stays anchored above the composer
|
||||
(2026-07-12).
|
||||
- Pet and star animations now retain stable identities and smoother timing,
|
||||
reducing flicker and visible jumps around worker activity
|
||||
(2026-07-12).
|
||||
- Repeated Slack mention events no longer create duplicate tasks or replies,
|
||||
and connector status and failure feedback are clearer in Settings
|
||||
(2026-07-12).
|
||||
- Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns)
|
||||
regardless of actual activity; tool events now carry their own run
|
||||
attribution (2026-07-09).
|
||||
|
||||
## v0.2.0 (2026-07-09)
|
||||
|
||||
A month of work since the initial release, centered on shared workspaces,
|
||||
|
||||
@ -90,6 +90,34 @@ llm:
|
||||
# max_concurrency: 1
|
||||
# enabled: true
|
||||
|
||||
# 例: extra_body / reasoning_efforts / reasoning_effort_mode
|
||||
# (vLLM で reasoning effort を渡す)。
|
||||
#
|
||||
# extra_body: OpenAI 互換リクエストボディへそのまま浅くマージされる任意 JSON。
|
||||
# model / messages / stream / stream_options / tools / tool_choice /
|
||||
# temperature は予約キーとしてクライアント側で無視される。
|
||||
# ここに API キー等の秘密情報を書かないこと — /api/config はこの値を
|
||||
# マスクしない (config.yaml に平文で残る)。
|
||||
# reasoning_efforts: このワーカーが対応する effort の宣言リスト (後続フェーズで
|
||||
# ジョブ単位の effort 指定に使われる)。
|
||||
# reasoning_effort_mode: effort をリクエストボディへ注入する形。
|
||||
# body (既定) = トップレベル reasoning_effort。vLLM 向け。
|
||||
# chat_template_kwargs = chat_template_kwargs.reasoning_effort。
|
||||
# llama-server (llama.cpp) 向け — llama-server はトップレベルの
|
||||
# reasoning_effort を黙って無視するため、llama-server を使う場合は
|
||||
# chat_template_kwargs を明示すること。
|
||||
# - id: vllm-reasoning
|
||||
# connection_type: direct
|
||||
# endpoint: http://localhost:8000/v1
|
||||
# model: deepseek-r1
|
||||
# roles: [quality]
|
||||
# max_concurrency: 1
|
||||
# enabled: true
|
||||
# extra_body:
|
||||
# reasoning_effort: max
|
||||
# reasoning_efforts: [low, medium, high, max]
|
||||
# reasoning_effort_mode: body
|
||||
|
||||
# Prometheus exporter (worker side). default で enabled。
|
||||
# /metrics が bridge HTTP server (PORT, default 9876) に mount される。
|
||||
# access control: default では localhost (127.0.0.1 / ::1) のみ通る。
|
||||
@ -472,6 +500,21 @@ tools:
|
||||
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
|
||||
# 鍵をローテーションする場合: npm run vapid-rotate
|
||||
|
||||
# ── ワークスペース Webhook 通知 (Discord / Slack / Teams) ────────────────────
|
||||
# Spec: issue #797. PR1 では Discord のみ実装(Slack/Teams は後続 PR)。
|
||||
# 通知先 URL はワークスペースごとに設定 UI から登録し、DB に AES-256-GCM で
|
||||
# 暗号化して保存する(config.yaml には書かない)。
|
||||
# webhooks:
|
||||
# enabled: false # true で有効化。false のときは送信を no-op する
|
||||
# timeout_ms: 10000 # 1 送信あたりの HTTP タイムアウト
|
||||
# payload_max_bytes: 8192 # 送信 payload の上限(バイト)
|
||||
# max_redirects: 5 # ssrfSafeFetch が辿るリダイレクト上限
|
||||
# queue_concurrency: 8 # 送信キューの同時実行数
|
||||
# retry:
|
||||
# max_attempts: 2 # 429 / 5xx / timeout 時の再試行回数(初回 + 2 = 最大 3 回送信)
|
||||
# backoff_ms: 500 # 再試行のベース backoff(指数バックオフ + jitter)
|
||||
# public_base_url: "https://maestro.example.com" # 通知本文のタスクリンクに使う絶対 URL(未設定は相対パス)
|
||||
|
||||
# ── A2A OAuth2 Authorization Server ──────────────────────────────────────────
|
||||
# Requires auth to be active (consent screen uses req.user).
|
||||
#
|
||||
|
||||
4
logs/bash-history.jsonl
Normal file
4
logs/bash-history.jsonl
Normal file
@ -0,0 +1,4 @@
|
||||
{"timestamp":"2026-07-12T23:49:58.285Z","command":"pip install pypdf","isError":true,"durationMs":0,"blocked":true}
|
||||
{"timestamp":"2026-07-12T23:49:58.286Z","command":"npm install left-pad","isError":true,"durationMs":1,"blocked":true}
|
||||
{"timestamp":"2026-07-12T23:49:58.287Z","command":"echo hello","isError":false,"durationMs":1,"outputBytes":6}
|
||||
{"timestamp":"2026-07-12T23:49:58.310Z","command":"node -e \"process.stdout.write(String(process.env.MCP_ENCRYPTION_KEY))\"","isError":false,"durationMs":22,"outputBytes":9}
|
||||
@ -16,6 +16,12 @@ movements:
|
||||
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
|
||||
最後に統合レポートにまとめる。重い調査は各サブに任せ、自分の文脈は軽く保つ。
|
||||
|
||||
成果物の上書き防止: システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とし、
|
||||
成果物は {DATE} 入りのパスに書く。**過去の実行が残したファイルは編集も削除もしない**。
|
||||
{DATE} 入りの成果物(output/report-{DATE}.md や output/research/{DATE}/ 等、途中で
|
||||
中断した実行の残骸も含む)が既にあり、それが別タスクのものなら、{DATE}-2, {DATE}-3 ...
|
||||
を全ファイル共通で使う。
|
||||
|
||||
手順:
|
||||
1. dig で立てた調査テーマを思い出す(2〜5 個程度)。各テーマに上から 1 始まりの
|
||||
連番を振る(1, 2, 3 ...)。
|
||||
@ -25,21 +31,21 @@ movements:
|
||||
- 調べるテーマと目的・背景
|
||||
- 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示
|
||||
- 「概要・主要な特徴・数値や事例・まとめと考察 の構成で
|
||||
output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは
|
||||
output/images/ に保存し Markdown で埋め込め」指示
|
||||
output/research/{DATE}/theme-{連番}.md に Write せよ({DATE} は展開済みの実日付で渡す)。
|
||||
関連する画像・グラフは output/images/{DATE}/ に保存し Markdown で埋め込め」指示
|
||||
- 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
|
||||
- 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」
|
||||
各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
|
||||
3. Glob で output/research/*.md の件数を確認する。
|
||||
4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する:
|
||||
3. Glob で output/research/{DATE}/*.md の件数を確認する。
|
||||
4. 各サブの要約とファイルを基に統合レポートを output/report-{DATE}.md に Write する:
|
||||
- 各テーマの主要な知見を統合(矛盾・重複は整理)
|
||||
- 比較・対照が必要なら表形式で整理
|
||||
- 全体のまとめと考察を付ける
|
||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
||||
5. output/report.md を書き終えたら verify へ遷移する
|
||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/{DATE}/theme-{連番}.md) で繋ぐ
|
||||
5. output/report-{DATE}.md を書き終えたら verify へ遷移する
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/report.md に統合レポートを作成した
|
||||
- condition: output/report-{DATE}.md に統合レポートを作成した
|
||||
next: verify
|
||||
|
||||
- name: dig
|
||||
@ -71,6 +77,15 @@ movements:
|
||||
WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。
|
||||
テキストで回答するだけでは不十分。
|
||||
|
||||
上書き防止(実行キーの決定・以後の全 movement で共有):
|
||||
システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を実行キーとする。ただし
|
||||
同じ日付入りの成果物(output/report-{日付}.md や作業ファイル・画像等)が既にあり
|
||||
別タスクのものなら、空いている {日付}-2, {日付}-3 ... を実行キーにする。
|
||||
作業ファイルはファイル名に実行キーを含め、画像は output/images/{実行キー}/ に保存する。
|
||||
キーを決めたら最初の作業ファイルをすぐ書き出してキーを確保する(並行する別タスクとの
|
||||
衝突の余地を減らす)。analyze・verify もこの実行キーを引き継いで使う。
|
||||
**過去の実行が残したファイルは編集も削除もしない**(読むのは可。内容は鵜呑みにしない)。
|
||||
|
||||
## 検索の原則(必須)
|
||||
|
||||
- モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る
|
||||
@ -88,7 +103,7 @@ movements:
|
||||
|
||||
## 画像・ビジュアル素材の収集(必須)
|
||||
|
||||
調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。
|
||||
調査中は画像・グラフ・図表を積極的に収集し、output/images/{実行キー}/ に保存すること。
|
||||
テキストだけの調査で終わらせない。ビジュアル素材がレポートの品質を大きく左右する。
|
||||
|
||||
収集すべきもの:
|
||||
@ -97,7 +112,7 @@ movements:
|
||||
- データの可視化(統計グラフ、トレンド図等)
|
||||
- 関連する図解・インフォグラフィック
|
||||
|
||||
収集した画像はレポートの Markdown から相対パスで参照する: ``
|
||||
収集した画像はレポートの Markdown から相対パスで参照する: ``
|
||||
|
||||
## 終了 / 遷移方法
|
||||
- **次の analyze へ**: `transition({next_step: "analyze"})`
|
||||
@ -122,6 +137,13 @@ movements:
|
||||
必ず Write ツールで output/ にレポートファイルを書き出すこと。
|
||||
前のステップから指摘事項がある場合は、それに対応すること。
|
||||
|
||||
成果物の上書き防止: レポートのファイル名には dig で決めた実行キーを入れる
|
||||
(例: output/report-2026-07-08.md、同日再実行なら output/report-2026-07-08-2.md)。
|
||||
dig を経ていない等でキーが不明な場合のみ、同じルール(「現在日時」の日付、
|
||||
別タスクの同日成果物があれば空いている -2, -3 ...)で決め直す。
|
||||
**過去の実行が残したレポートは編集も削除もしない**。
|
||||
このタスク内での verify 差し戻しでは同じファイルを修正してよい。
|
||||
|
||||
## 検索の原則(必須)
|
||||
|
||||
- レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと
|
||||
@ -131,8 +153,9 @@ movements:
|
||||
|
||||
## 画像の活用(必須)
|
||||
|
||||
output/images/ に画像が保存されている場合は、必ずレポートの該当箇所に埋め込む:
|
||||
``
|
||||
output/images/{実行キー}/ にこのタスクで収集した画像がある場合は、
|
||||
必ずレポートの該当箇所に実際の保存パスで埋め込む:
|
||||
``(例: `./images/2026-07-08/graph.png`)
|
||||
画像があるのにテキストだけのレポートにしないこと。
|
||||
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
||||
|
||||
@ -147,6 +170,13 @@ movements:
|
||||
persona: reviewer
|
||||
instruction: |
|
||||
output/ のレポートを確認する。
|
||||
審査対象は**このタスクの実行で作られたレポート**。ここまでの会話で書き出したファイル
|
||||
パスをそのまま使う。パスが会話から分からない場合のみ Glob output/report-*.md で列挙し、
|
||||
GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
|
||||
(-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
|
||||
無印が -2 より後に並ぶため使わない)。
|
||||
過去の実行が残したレポートは審査対象外で、編集・削除もしない。
|
||||
|
||||
確認手順:
|
||||
1. まず Glob で output/ 内のファイル一覧を確認する
|
||||
@ -169,8 +199,9 @@ movements:
|
||||
- ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す
|
||||
|
||||
追加チェック(画像):
|
||||
- output/images/ に画像があるのにレポートに ` で繋ぐ。
|
||||
- Glob で output/deepdive/{DATE}/*.md の件数を確認する。
|
||||
- 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report-{DATE}.md に Write する。
|
||||
各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/{DATE}/tweet-{連番}.md) で繋ぐ。
|
||||
- 仕上がったら complete({status: "success", result: ...}) を呼ぶ。
|
||||
result は output/report.md の内容をベースに、ユーザー向けの最終回答として整形する。
|
||||
result は output/report-{DATE}.md の内容をベースに、ユーザー向けの最終回答として整形する。
|
||||
「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。
|
||||
|
||||
### 原則
|
||||
|
||||
@ -12,6 +12,16 @@ movements:
|
||||
- name: gather
|
||||
persona: researcher
|
||||
instruction: |
|
||||
## 実行日 {DATE}(過去の成果物の上書き防止)
|
||||
システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とする。
|
||||
成果物は必ず {DATE} 入りのパスに書き、**過去の実行が残したファイルは編集も削除もしない**。
|
||||
{DATE} 入りの成果物が1つでも既に存在し(output/raw/{DATE}/ だけでなく、他の調査 piece が
|
||||
作った output/report-{DATE}.md 等も含めて確認する)、それが別タスクのものなら
|
||||
{DATE}-2, {DATE}-3 ... を全ファイル共通で使う
|
||||
(このタスク内で gather に戻ってきた場合は同じディレクトリに追記してよい)。
|
||||
{DATE} を決めたら、最初の収集結果をすぐ output/raw/{DATE}/ に書き出してキーを確保する
|
||||
(並行する別タスクとの衝突の余地を減らす)。
|
||||
|
||||
## 調査計画
|
||||
|
||||
着手前に調査計画を立てる:
|
||||
@ -19,7 +29,7 @@ movements:
|
||||
2. 検索クエリ案を複数考える(日本語・英語の両方を検討)
|
||||
3. verify からの差し戻しがある場合は、不足点を優先的に解消する
|
||||
|
||||
計画に従って SNS から情報を収集し、Write で output/raw/ にテキストファイルとして書き出す。
|
||||
計画に従って SNS から情報を収集し、Write で output/raw/{DATE}/ にテキストファイルとして書き出す。
|
||||
|
||||
## SNS 別の収集方針
|
||||
|
||||
@ -40,8 +50,8 @@ movements:
|
||||
- 記事詳細: `https://hn.algolia.com/api/v1/items/{id}`
|
||||
|
||||
## ファイル命名規則
|
||||
`output/raw/{platform}-{query-slug}.txt`
|
||||
例: reddit-ollama-vs-vllm.txt, x-ollama-review.txt, hn-local-llm.txt
|
||||
`output/raw/{DATE}/{platform}-{query-slug}.txt`
|
||||
例: output/raw/2026-07-08/reddit-ollama-vs-vllm.txt, output/raw/2026-07-08/x-ollama-review.txt
|
||||
|
||||
## SNS 調査の原則
|
||||
モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。
|
||||
@ -49,7 +59,7 @@ movements:
|
||||
|
||||
## 画像・スクリーンショットの収集
|
||||
SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で
|
||||
`output/images/{platform}-{slug}.png` に保存する。
|
||||
`output/images/{DATE}/{platform}-{slug}.png` に保存する。
|
||||
|
||||
## 終了 / 遷移方法
|
||||
- **次の analyze へ**: `transition({next_step: "analyze"})`
|
||||
@ -68,12 +78,20 @@ movements:
|
||||
instruction: |
|
||||
output/raw/ の収集データを読み込み、分析してレポートを作成する。
|
||||
|
||||
このタスクの gather が書き出した日付ディレクトリを {DATE} とする。
|
||||
会話から分からない場合のみ、Glob output/raw/*/* でファイルを列挙し
|
||||
(ディレクトリ末尾の output/raw/*/ はファイルにマッチせず常に空になるので使わない)、
|
||||
GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ親ディレクトリ名の日付と連番を数値として解釈して
|
||||
最新のキーを選ぶ(-2 は無印より新しく -10 は -9 より新しい)。
|
||||
過去の実行のファイルは編集・削除しない。
|
||||
|
||||
手順:
|
||||
1. Glob で output/raw/ 内のファイル一覧を確認
|
||||
1. Glob で output/raw/{DATE}/ 内のファイル一覧を確認
|
||||
2. 各ファイルを Read で読み込む
|
||||
3. 重要な意見・トレンド・共通見解を抽出
|
||||
4. ポジティブ/ネガティブな意見を分類
|
||||
5. output/report.md にレポートを書き出す
|
||||
5. output/report-{DATE}.md にレポートを書き出す
|
||||
|
||||
## レポートの構成
|
||||
- トピック概要
|
||||
@ -82,15 +100,15 @@ movements:
|
||||
- まとめ
|
||||
|
||||
## 画像の活用
|
||||
output/images/ に画像がある場合は必ずレポートに埋め込む:
|
||||
``
|
||||
output/images/{DATE}/ に画像がある場合は必ずレポートに埋め込む:
|
||||
``
|
||||
|
||||
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
|
||||
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
|
||||
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/report.md にレポートを書き出した
|
||||
- condition: output/report-{DATE}.md にレポートを書き出した
|
||||
next: verify
|
||||
- condition: 情報が不十分で追加収集が必要
|
||||
next: gather
|
||||
@ -99,10 +117,17 @@ movements:
|
||||
persona: supervisor
|
||||
instruction: |
|
||||
output/ のレポートを確認する。
|
||||
審査対象はこのタスクの analyze が書いたレポート(output/report-{DATE}.md)。ここまでの
|
||||
会話で書き出したパスをそのまま使う。会話から分からない場合のみ Glob output/report-*.md で
|
||||
列挙し、GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
|
||||
(-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
|
||||
無印が -2 より後に並ぶため使わない)。
|
||||
過去の実行のレポートは対象外で、編集・削除もしない。
|
||||
|
||||
確認手順:
|
||||
1. Glob で output/ 内のファイル一覧を確認する
|
||||
2. output/report.md がなければ「不足がある」と判断し analyze に差し戻す
|
||||
2. output/report-{DATE}.md がなければ「不足がある」と判断し analyze に差し戻す
|
||||
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
|
||||
4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く:
|
||||
[判定] needs_fix
|
||||
@ -117,7 +142,7 @@ movements:
|
||||
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
|
||||
|
||||
追加チェック(画像):
|
||||
- output/images/ に画像があるのにレポートに `![` が一つもない場合、
|
||||
- output/images/{DATE}/ に画像があるのにレポートに `![` が一つもない場合、
|
||||
画像埋め込み漏れとして analyze に差し戻す
|
||||
|
||||
## チェックシート確認
|
||||
@ -126,7 +151,7 @@ movements:
|
||||
|
||||
## 合格時のユーザーへの返答(complete ツール)
|
||||
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
|
||||
result はそのままユーザーに表示される最終回答。output/report.md を Read で読み、その内容をベースに整形する。
|
||||
result はそのままユーザーに表示される最終回答。output/report-{DATE}.md を Read で読み、その内容をベースに整形する。
|
||||
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
|
||||
- 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること
|
||||
- 調査結果・発見・結論を会話調で分かりやすく伝える
|
||||
|
||||
@ -22,6 +22,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
||||
"USE name=requireAdmin path=^\\/api\\/config\\/?(?=\\/|$)",
|
||||
"USE name=requireAuth path=^\\/api\\/pieces\\/?(?=\\/|$)",
|
||||
"USE name=requireAuth path=^\\/api\\/usage\\/?(?=\\/|$)",
|
||||
"USE name=requireAuth path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)",
|
||||
"USE name=requireAuth path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
||||
"USE name=requireAuth path=^\\/api\\/scheduled-tasks\\/?(?=\\/|$)",
|
||||
"USE name=jsonParser path=^\\/api\\/admin\\/?(?=\\/|$)",
|
||||
@ -60,6 +61,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
||||
"ROUTE get /",
|
||||
"ROUTE get /api/version",
|
||||
"ROUTE get /api/repos",
|
||||
"USE name=router path=^\\/api\\/?(?=\\/|$)",
|
||||
"ROUTE get /api/local/tasks",
|
||||
"ROUTE post /api/local/tasks",
|
||||
"ROUTE get /api/local/tasks/:taskId",
|
||||
@ -78,6 +80,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
||||
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
||||
"ROUTE post /api/local/tasks/evaluate-prompt",
|
||||
"ROUTE get /api/local/tasks/:taskId/stream",
|
||||
"ROUTE get /api/local/tasks/:taskId/movement-history",
|
||||
"ROUTE get /api/local/tasks/:taskId/files",
|
||||
"ROUTE get /api/local/tasks/:taskId/files/content",
|
||||
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
||||
@ -91,6 +94,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
|
||||
"USE name=router path=^\\/api\\/local\\/tasks\\/?(?=\\/|$)",
|
||||
"USE name=router path=^\\/api\\/usage\\/?(?=\\/|$)",
|
||||
"USE name=router path=^\\/api\\/calendar\\/?(?=\\/|$)",
|
||||
"USE name=router path=^\\/api\\/llm\\/workers\\/?(?=\\/|$)",
|
||||
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files",
|
||||
"ROUTE get /api/local/tasks/:id/subtasks/:jobId/files/*",
|
||||
"ROUTE get /api/jobs/:jobId",
|
||||
@ -178,6 +182,7 @@ exports[`createCoreServer route registration order > pins the no-auth minimal bo
|
||||
"ROUTE get /",
|
||||
"ROUTE get /api/version",
|
||||
"ROUTE get /api/repos",
|
||||
"USE name=router path=^\\/api\\/?(?=\\/|$)",
|
||||
"ROUTE get /api/local/tasks",
|
||||
"ROUTE post /api/local/tasks",
|
||||
"ROUTE get /api/local/tasks/:taskId",
|
||||
@ -196,6 +201,7 @@ exports[`createCoreServer route registration order > pins the no-auth minimal bo
|
||||
"ROUTE post /api/local/tasks/:taskId/regenerate-title",
|
||||
"ROUTE post /api/local/tasks/evaluate-prompt",
|
||||
"ROUTE get /api/local/tasks/:taskId/stream",
|
||||
"ROUTE get /api/local/tasks/:taskId/movement-history",
|
||||
"ROUTE get /api/local/tasks/:taskId/files",
|
||||
"ROUTE get /api/local/tasks/:taskId/files/content",
|
||||
"ROUTE get /api/local/tasks/:taskId/files/provenance",
|
||||
|
||||
@ -10,6 +10,7 @@ import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js';
|
||||
import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js';
|
||||
import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js';
|
||||
import { mountA2aRequestHandler } from './a2a/request-handler.js';
|
||||
import { A2aLimiter, resolveA2aLimits } from './a2a/limiter.js';
|
||||
import { A2aTaskReconciler } from './a2a/reconciler.js';
|
||||
import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js';
|
||||
|
||||
@ -19,11 +20,19 @@ import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a
|
||||
* `a2a.enabled: true` in config.yaml. Registration order relative to the
|
||||
* other /api/local mounts is preserved by the call site in server.ts.
|
||||
*/
|
||||
export interface A2aSubsystemResult {
|
||||
/** The A2A governance limiter, when a2a.enabled. Shared with the chat
|
||||
* connector subsystem (see chat-subsystem.ts) so rate/concurrency/
|
||||
* skill-budget counters are keyed per-grant regardless of entry point. */
|
||||
limiter?: A2aLimiter;
|
||||
}
|
||||
|
||||
export function setupA2aSubsystem(
|
||||
app: express.Express,
|
||||
deps: { repo: Repository; authActive: boolean },
|
||||
): void {
|
||||
): A2aSubsystemResult {
|
||||
const { repo, authActive } = deps;
|
||||
let limiter: A2aLimiter | undefined;
|
||||
|
||||
// --- A2A OAuth2 Authorization Server ---
|
||||
const a2aCfg = (loadConfig() as any).a2a ?? {};
|
||||
@ -50,10 +59,14 @@ export function setupA2aSubsystem(
|
||||
const a2aBaseUrl = (() => {
|
||||
try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); }
|
||||
})();
|
||||
app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' }));
|
||||
// JSON-RPC と拡張カード(REST)の両経路で 1 grant のレート予算を共有するため、
|
||||
// limiter は 1 インスタンスをここで生成して両者に渡す。
|
||||
const a2aLimiter = new A2aLimiter(resolveA2aLimits(a2aCfg.limits));
|
||||
limiter = a2aLimiter;
|
||||
app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter }));
|
||||
// JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由)
|
||||
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。
|
||||
mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limits: a2aCfg.limits });
|
||||
mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter });
|
||||
// bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では
|
||||
// lease が必要になる(将来課題)。
|
||||
const a2aReconciler = new A2aTaskReconciler({ repo });
|
||||
@ -69,6 +82,8 @@ export function setupA2aSubsystem(
|
||||
logger.info('[a2a-oidc] a2a authorization server enabled');
|
||||
} catch (err) {
|
||||
logger.warn(`[a2a-oidc] failed to mount a2a authorization server: ${err}`);
|
||||
limiter = undefined;
|
||||
}
|
||||
}
|
||||
return { limiter };
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { computeEffectiveScope } from './delegation.js';
|
||||
import { canManageSpace } from '../visibility.js';
|
||||
import { viewerOf } from '../space-viewer.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { A2aLimiter } from './limiter.js';
|
||||
|
||||
export interface A2aRouterDeps {
|
||||
provider: Provider;
|
||||
@ -23,6 +24,12 @@ export interface A2aRouterDeps {
|
||||
baseUrl: string;
|
||||
issuer: string;
|
||||
version: string;
|
||||
/**
|
||||
* 共有レート limiter(任意)。指定時、extended card ルートは認証直後・scope 計算前に
|
||||
* grant 単位のレートを消費し、超過なら 429 を返す(scope 計算も監査書き込みもしない)。
|
||||
* JSON-RPC 側の getAuthenticatedExtendedAgentCard と同一 limiter を共有する想定。
|
||||
*/
|
||||
limiter?: A2aLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -61,6 +68,15 @@ export function createA2aRouter(deps: A2aRouterDeps): Router {
|
||||
return;
|
||||
}
|
||||
|
||||
// レート制限(limiter 注入時のみ): 認証直後・scope 計算前に消費する。超過時は
|
||||
// 429 を返し、以降の DB 読み込みも監査書き込みも行わない(書き込み増幅を防ぐ)。
|
||||
if (deps.limiter && !deps.limiter.tryConsumeRate(principal.grantId)) {
|
||||
// 行儀の良いクライアントのバックオフ用に概算の待機秒数を返す。
|
||||
const retryAfter = Math.max(1, Math.ceil(60 / deps.limiter.limits.ratePerMinute));
|
||||
res.status(429).set('Retry-After', String(retryAfter)).json({ error: 'rate limit exceeded' });
|
||||
return;
|
||||
}
|
||||
|
||||
// acting user の可視スペース・スキルを delegation の AND 交差で算出(IDOR 防止)
|
||||
const scope = await computeEffectiveScope(deps.repo, principal);
|
||||
if (!scope) {
|
||||
|
||||
@ -10,6 +10,8 @@ import { join } from 'path';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
|
||||
import { createA2aRouter } from './a2a-api.js';
|
||||
import { mountA2aRequestHandler } from './request-handler.js';
|
||||
import { A2aLimiter, resolveA2aLimits } from './limiter.js';
|
||||
import { b64url, Client } from './__e2e-helpers.js';
|
||||
|
||||
/**
|
||||
@ -86,14 +88,26 @@ describe('A2A card e2e (real token → scoped extended card + negative paths)',
|
||||
// テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
|
||||
(provider as any).proxy = false;
|
||||
|
||||
// 実 provider + consent を /oidc に、A2A card ルータを同じ app にマウント。
|
||||
// 実 provider + consent を /oidc に、A2A card ルータ + JSON-RPC ハンドラを同じ app に
|
||||
// マウント。両者に「同一」limiter を渡すことで a2a-subsystem の共有配線を再現する。
|
||||
// ratePerMinute:2 は各テストが 1 grant あたり最大 2 回しか叩かないため既存ケースに
|
||||
// 影響しない(obtainToken ごとに新しい grantId=新しいバケット)。
|
||||
const sharedLimiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 2 }));
|
||||
mountA2aOidc(app, provider, { repo, resourceAudience: audience });
|
||||
app.use(createA2aRouter({
|
||||
provider, repo,
|
||||
baseUrl: base,
|
||||
issuer: `${base}/oidc`,
|
||||
version: '1.0.0-test',
|
||||
limiter: sharedLimiter,
|
||||
}));
|
||||
mountA2aRequestHandler(app, {
|
||||
provider, repo,
|
||||
baseUrl: base,
|
||||
issuer: `${base}/oidc`,
|
||||
version: '1.0.0-test',
|
||||
limiter: sharedLimiter,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => { server?.close(); });
|
||||
@ -216,6 +230,53 @@ describe('A2A card e2e (real token → scoped extended card + negative paths)',
|
||||
expect(ids).not.toContain('summarize');
|
||||
});
|
||||
|
||||
it('rate limit: extended card throttles past ratePerMinute and writes no audit row on 429', async () => {
|
||||
// Fresh grant → fresh token-bucket seeded to ratePerMinute:2. Third call on the same
|
||||
// token exhausts it. The 429 must be enforced BEFORE the scope computation + audit write,
|
||||
// so the audit_log count for a2a.card.extended must not grow across the throttled call.
|
||||
const { accessToken } = await obtainToken(spaceId, 'research');
|
||||
const hdr = { authorization: `Bearer ${accessToken}` };
|
||||
|
||||
const r1 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
|
||||
const r2 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
const auditBefore = (repo as any).db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'")
|
||||
.get().n as number;
|
||||
|
||||
const r3 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr });
|
||||
expect(r3.status).toBe(429);
|
||||
|
||||
const auditAfter = (repo as any).db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'")
|
||||
.get().n as number;
|
||||
expect(auditAfter).toBe(auditBefore); // throttled request did not touch the DB
|
||||
});
|
||||
|
||||
it('shared limiter: REST card drain throttles the JSON-RPC extended-card RPC on the same grant', async () => {
|
||||
// Core invariant of this change: the REST route and the JSON-RPC handler share ONE limiter
|
||||
// (mirrors a2a-subsystem wiring). Draining the grant's bucket via 2 REST calls must throttle
|
||||
// the RPC surface too. If each surface held its own bucket, the RPC would still have 2 tokens
|
||||
// and return a result — so a `rate limit` error here proves the buckets are shared.
|
||||
const { accessToken } = await obtainToken(spaceId, 'research');
|
||||
const hdr = { authorization: `Bearer ${accessToken}` };
|
||||
|
||||
expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200);
|
||||
expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200);
|
||||
// bucket for this grant is now empty — the RPC surface must observe it
|
||||
|
||||
const rpcRes = await fetch(`${base}/a2a`, {
|
||||
method: 'POST',
|
||||
headers: { ...hdr, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent/getAuthenticatedExtendedCard' }),
|
||||
});
|
||||
const body = await rpcRes.json() as { result?: unknown; error?: { code: number; message: string } };
|
||||
expect(body.error).toBeDefined(); // NOT a successful card result
|
||||
expect(body.error!.message).toMatch(/rate limit/); // shared bucket already drained by REST
|
||||
});
|
||||
|
||||
it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => {
|
||||
// space-a(research)と space-b(analyze)が両方ユーザーから見えるが、
|
||||
// space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、
|
||||
|
||||
@ -262,19 +262,23 @@ describe('A2A exec e2e (real token → message/send → job → artifact + negat
|
||||
expect(taskRow.piece_name).toBe('research');
|
||||
}, 30_000);
|
||||
|
||||
it('no bearer → terminal failed Task, no job created', async () => {
|
||||
it('no bearer → JSON-RPC error at ingress gate, no job and no a2a_task persisted', async () => {
|
||||
const before = jobCountForUser();
|
||||
const a2aBefore = ((repo as any).db.prepare('SELECT COUNT(*) AS n FROM a2a_tasks').get() as { n: number }).n;
|
||||
const res = await sendMessage(messageSendEnvelope('do research'));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { kind?: string; status?: { state: string } } };
|
||||
const json = await res.json() as { jsonrpc: string; error?: { code: number }; result?: unknown };
|
||||
expect(json.jsonrpc).toBe('2.0');
|
||||
// 未認証 → executor が principal 無しで完全な terminal Task(state=failed) を publish する。
|
||||
// (2C-3 Task 4 で SDK ResultManager 互換に修正: 旧挙動は bare status-update が drop され error 化していた)
|
||||
expect(json.error).toBeUndefined();
|
||||
expect(json.result?.kind).toBe('task');
|
||||
expect(json.result?.status?.state).toBe('failed');
|
||||
// 2C-3 review #2: 未認証は AuthGatedRequestHandler.enforceIngress() が execute() 前に弾く。
|
||||
// クリーンな JSON-RPC エラー(-32600)を返し、タスク行も監査行も一切書かない(書き込み増幅の遮断)。
|
||||
// 旧挙動(executor が failed Task を publish → 永続化)から意図的に変更。
|
||||
expect(json.error?.code).toBe(-32600);
|
||||
expect(json.result).toBeUndefined();
|
||||
// ジョブは作られない(最重要のセキュリティ不変条件)。
|
||||
expect(jobCountForUser()).toBe(before);
|
||||
// a2a_tasks 行も増えない(#2 の核心 — フラッドで行が増えないことを保証)。
|
||||
const a2aAfter = ((repo as any).db.prepare('SELECT COUNT(*) AS n FROM a2a_tasks').get() as { n: number }).n;
|
||||
expect(a2aAfter).toBe(a2aBefore);
|
||||
}, 30_000);
|
||||
|
||||
it('out-of-scope skill (summarize, allowlisted but not consented) → fail-closed, no job created', async () => {
|
||||
|
||||
@ -542,54 +542,11 @@ describe('MaestroA2aExecutor', () => {
|
||||
const NOW_ISO = '2026-07-07T00:00:00.000Z';
|
||||
const nowFn = () => NOW_ISO;
|
||||
|
||||
it('payload guard: oversized message → rejected Task (kind:task), createLocalTask not called', async () => {
|
||||
const repo = makeFakeRepo();
|
||||
const eventBus = makeFakeEventBus();
|
||||
// maxPayloadBytes: 1 なので userMessage を JSON 化した時点で確実にオーバー
|
||||
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 1 }, () => 0);
|
||||
const executor = new MaestroA2aExecutor(repo as any, {
|
||||
pollMs: 0,
|
||||
sleep: async () => {},
|
||||
now: nowFn,
|
||||
limiter,
|
||||
});
|
||||
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL, messageText: 'hello' });
|
||||
|
||||
await executor.execute(ctx as any, eventBus as any);
|
||||
|
||||
expect(eventBus.finishedCalled).toBe(true);
|
||||
// kind:'task' (pre-initial-Task path) with state='rejected'
|
||||
const rejected = eventBus.published.find(
|
||||
(e: any) => e.kind === 'task' && e.status?.state === 'rejected',
|
||||
);
|
||||
expect(rejected).toBeDefined();
|
||||
// No job row created
|
||||
expect(repo.createLocalTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rate guard: rate bucket drained → rejected Task (kind:task), createLocalTask not called', async () => {
|
||||
const repo = makeFakeRepo();
|
||||
const eventBus = makeFakeEventBus();
|
||||
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 }, () => 0);
|
||||
// Pre-drain: consume the 1 available token so the next call is rejected
|
||||
limiter.tryConsumeRate('grant-1');
|
||||
const executor = new MaestroA2aExecutor(repo as any, {
|
||||
pollMs: 0,
|
||||
sleep: async () => {},
|
||||
now: nowFn,
|
||||
limiter,
|
||||
});
|
||||
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL });
|
||||
|
||||
await executor.execute(ctx as any, eventBus as any);
|
||||
|
||||
expect(eventBus.finishedCalled).toBe(true);
|
||||
const rejected = eventBus.published.find(
|
||||
(e: any) => e.kind === 'task' && e.status?.state === 'rejected',
|
||||
);
|
||||
expect(rejected).toBeDefined();
|
||||
expect(repo.createLocalTask).not.toHaveBeenCalled();
|
||||
});
|
||||
// NOTE: payload + rate guards moved OUT of execute() into
|
||||
// AuthGatedRequestHandler.enforceIngress() (2C-3 review #2 — write amplification).
|
||||
// They now throw a JSON-RPC error BEFORE execute() runs, so nothing is persisted;
|
||||
// their coverage lives in request-handler-auth-gate.test.ts. Only concurrency +
|
||||
// skill-budget remain here — they need the reservation lifecycle inside execute().
|
||||
|
||||
it('concurrency guard: db count at cap → rejected Task (kind:task), createLocalTask not called', async () => {
|
||||
const repo = makeFakeRepo();
|
||||
@ -663,6 +620,35 @@ describe('MaestroA2aExecutor', () => {
|
||||
expect(submitted).toBeDefined();
|
||||
});
|
||||
|
||||
it('executor is UNGUARDED for payload/rate — enforcement lives solely in the ingress gate (review #9)', async () => {
|
||||
// Explicit invariant marker: payload + rate moved to AuthGatedRequestHandler.enforceIngress().
|
||||
// The executor no longer rejects an oversized or over-rate message; it proceeds to create the
|
||||
// task. This documents that the handler gate is load-bearing — if a future path reaches
|
||||
// execute() without the gate, oversized/over-rate requests would run unchecked (and, being
|
||||
// post-execute, WOULD persist a row — which is exactly why the guard must stay upstream, not here).
|
||||
const repo = makeFakeRepo();
|
||||
const eventBus = makeFakeEventBus();
|
||||
// maxPayloadBytes: 1 AND a fully-drained rate bucket — both would have rejected in the old code.
|
||||
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 1, ratePerMinute: 1 }, () => 0);
|
||||
limiter.tryConsumeRate('grant-1'); // drain — old executor would have rejected on rate too
|
||||
const executor = new MaestroA2aExecutor(repo as any, {
|
||||
pollMs: 0,
|
||||
sleep: async () => {},
|
||||
now: nowFn,
|
||||
limiter,
|
||||
});
|
||||
const ctx = makeRequestContext({ a2aPrincipal: DEFAULT_PRINCIPAL, messageText: 'this message is far larger than one byte' });
|
||||
|
||||
await executor.execute(ctx as any, eventBus as any);
|
||||
|
||||
// Not rejected for payload/rate — the task proceeds and the local task IS created.
|
||||
const rejectedForGovernance = eventBus.published.find(
|
||||
(e: any) => e.kind === 'task' && e.status?.state === 'rejected',
|
||||
);
|
||||
expect(rejectedForGovernance).toBeUndefined();
|
||||
expect(repo.createLocalTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reservation release: skill_budget reject after reservation → slot freed, no leak', async () => {
|
||||
const repo = makeFakeRepo();
|
||||
const eventBus = makeFakeEventBus();
|
||||
|
||||
@ -200,21 +200,11 @@ export class MaestroA2aExecutor implements AgentExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1b. ガバナンス: payload サイズ + レートリミット(scope 算出前に弾く = flood に scope DB 処理させない)
|
||||
if (this.limiter) {
|
||||
const L = this.limiter.limits;
|
||||
const bytes = Buffer.byteLength(JSON.stringify(requestContext.userMessage ?? {}), 'utf8');
|
||||
if (bytes > L.maxPayloadBytes) {
|
||||
await this.rejectGoverned(eventBus, taskId, contextId, principal, `payload too large: ${bytes} > ${L.maxPayloadBytes}`, 'payload', initialTaskPublished);
|
||||
safeFinished();
|
||||
return;
|
||||
}
|
||||
if (!this.limiter.tryConsumeRate(principal.grantId)) {
|
||||
await this.rejectGoverned(eventBus, taskId, contextId, principal, `rate limit exceeded (${L.ratePerMinute}/min)`, 'rate', initialTaskPublished);
|
||||
safeFinished();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 1b. ガバナンス: payload サイズ + レートリミットは AuthGatedRequestHandler.enforceIngress()
|
||||
// が execute() 呼び出し前に施行する。ここで拒否 Task を publish すると SDK ResultManager が
|
||||
// それを永続化してしまい(拒否のたびに a2a_tasks 行が増える書き込み増幅)、rate-limit の
|
||||
// フラッド防御を自ら損なうため、payload/rate はこの層では扱わない。concurrency と
|
||||
// skill-budget は予約ライフサイクルの都合でこの下に残す。
|
||||
|
||||
// 2. acting user の実際の可視スコープを算出(IDOR 防止・fail-closed)
|
||||
const scope = await computeEffectiveScope(this.repo, principal);
|
||||
|
||||
@ -216,6 +216,76 @@ describe('AuthGatedRequestHandler — resubscribe slot cap (Task 7)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ingress governance: sendMessage / sendMessageStream (2C-3 review #2) ─────
|
||||
// payload + rate are enforced here, BEFORE execute() runs, so a denial throws a JSON-RPC
|
||||
// error and persists nothing (the SDK only writes an a2a_tasks row once execute() publishes).
|
||||
|
||||
describe('AuthGatedRequestHandler — ingress governance (send/stream)', () => {
|
||||
const MSG = {
|
||||
message: { kind: 'message', role: 'user', messageId: 'm1', parts: [{ kind: 'text', text: 'hello world' }] },
|
||||
} as any;
|
||||
|
||||
function makeSendHandler(limiter: A2aLimiter) {
|
||||
const store = new InMemoryTaskStore();
|
||||
const execute = vi.fn(async () => {});
|
||||
const fakeExecutor = { execute } as any;
|
||||
const baseCard = buildBaseCard(BASE_DEPS);
|
||||
const h = new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);
|
||||
return { h, execute };
|
||||
}
|
||||
|
||||
it('sendMessage: oversized payload → throws -32600 and execute() is never invoked', async () => {
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ maxPayloadBytes: 1, ratePerMinute: 100 }));
|
||||
const { h, execute } = makeSendHandler(limiter);
|
||||
await expect(h.sendMessage(MSG, AUTH_CTX)).rejects.toMatchObject({ code: -32600 });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendMessage: rate bucket drained → throws -32600 and execute() is never invoked', async () => {
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1, maxPayloadBytes: 65536 }));
|
||||
limiter.tryConsumeRate('g1'); // drain the single token (AUTH_CTX principal grantId = g1)
|
||||
const { h, execute } = makeSendHandler(limiter);
|
||||
await expect(h.sendMessage(MSG, AUTH_CTX)).rejects.toMatchObject({ code: -32600 });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendMessage: unauthenticated → throws -32600 (no failed Task persisted, no execute)', async () => {
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 100 }));
|
||||
const { h, execute } = makeSendHandler(limiter);
|
||||
await expect(h.sendMessage(MSG, UNAUTH_CTX)).rejects.toMatchObject({ code: -32600 });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendMessageStream: oversized payload → SYNCHRONOUS throw before the generator (no SSE, no execute)', () => {
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ maxPayloadBytes: 1, ratePerMinute: 100 }));
|
||||
const { h, execute } = makeSendHandler(limiter);
|
||||
expect(() => h.sendMessageStream(MSG, AUTH_CTX)).toThrow(A2AError);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sendMessageStream: rate drained → synchronous throw before the generator', () => {
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1, maxPayloadBytes: 65536 }));
|
||||
limiter.tryConsumeRate('g1');
|
||||
const { h, execute } = makeSendHandler(limiter);
|
||||
expect(() => h.sendMessageStream(MSG, AUTH_CTX)).toThrow(A2AError);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('within limits: gate passes and consumes a rate token (second call is throttled)', () => {
|
||||
// sendMessageStream returns the generator synchronously; the base body (and execute())
|
||||
// only runs on the first .next(), which would block on a quiet stub — so we assert the
|
||||
// gate side effects instead: the first call passes and drains the single token, the
|
||||
// second call is rejected, proving the first consumed it.
|
||||
const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1, maxPayloadBytes: 65536 }));
|
||||
const { h } = makeSendHandler(limiter);
|
||||
let gen: AsyncGenerator<any> | undefined;
|
||||
expect(() => { gen = h.sendMessageStream(MSG, AUTH_CTX); }).not.toThrow();
|
||||
expect(gen).toBeDefined();
|
||||
void gen!.return?.(undefined); // unstarted generator → completes without running the body
|
||||
expect(() => h.sendMessageStream(MSG, AUTH_CTX)).toThrow(A2AError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── maxStreamSeconds fires for a quiet stream (Task 7 review fix) ───────────
|
||||
|
||||
describe('AuthGatedRequestHandler — maxStreamSeconds wall-clock race', () => {
|
||||
@ -353,6 +423,63 @@ describe('AuthGatedRequestHandler — fail-closed on missing grantId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAuthenticatedExtendedAgentCard: auth + rate (extended-card metering) ──
|
||||
// The RPC surface for the authenticated extended card. Like getTask/cancelTask it must
|
||||
// require auth, fail-closed on a missing grantId, and consume a rate token per call so an
|
||||
// authenticated grant cannot poll the scope-computing card endpoint unmetered.
|
||||
|
||||
describe('AuthGatedRequestHandler.getAuthenticatedExtendedAgentCard', () => {
|
||||
const NO_GRANT_CTX = new ServerCallContext(
|
||||
undefined,
|
||||
new A2aAuthenticatedUser({ ...FAKE_PRINCIPAL, grantId: undefined } as any),
|
||||
);
|
||||
// Provider is a function → SDK returns its result. base card carries
|
||||
// supportsAuthenticatedExtendedCard:true so the SDK reaches the provider.
|
||||
const extendedCardProvider = async () => buildBaseCard(BASE_DEPS);
|
||||
|
||||
function makeExtHandler(limiter?: A2aLimiter) {
|
||||
const store = new InMemoryTaskStore();
|
||||
const fakeExecutor = { execute: async () => {} } as any;
|
||||
const baseCard = buildBaseCard(BASE_DEPS);
|
||||
return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, extendedCardProvider, limiter);
|
||||
}
|
||||
|
||||
it('rejects UnauthenticatedUser with INVALID_REQUEST (-32600)', async () => {
|
||||
const h = makeExtHandler();
|
||||
await expect(h.getAuthenticatedExtendedAgentCard(UNAUTH_CTX))
|
||||
.rejects.toMatchObject({ code: -32600 });
|
||||
});
|
||||
|
||||
it('authenticated within rate returns the extended card', async () => {
|
||||
const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 5 })));
|
||||
const card = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX);
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
|
||||
it('second call past rate=1 throws A2AError (rate limit exceeded)', async () => {
|
||||
// Frozen clock → no token refill between the two calls (deterministic, no wall-clock flake).
|
||||
const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 }), () => 1000));
|
||||
// First call must SUCCEED (drains the single token) — assert it, don't swallow it.
|
||||
await expect(h.getAuthenticatedExtendedAgentCard(AUTH_CTX)).resolves.toBeDefined();
|
||||
const err = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX).catch(e => e);
|
||||
expect(err).toBeInstanceOf(A2AError);
|
||||
expect(err.code).toBe(-32600);
|
||||
expect(err.message).toMatch(/rate limit/);
|
||||
});
|
||||
|
||||
it('fail-closed: authenticated caller with no grantId denied when limiter active', async () => {
|
||||
const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 100 })));
|
||||
await expect(h.getAuthenticatedExtendedAgentCard(NO_GRANT_CTX))
|
||||
.rejects.toMatchObject({ code: -32600 });
|
||||
});
|
||||
|
||||
it('without a limiter the card is unmetered (auth passes, no rate error)', async () => {
|
||||
const h = makeExtHandler();
|
||||
const card = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX);
|
||||
expect(card).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ───────────
|
||||
|
||||
describe('mountA2aRequestHandler – tasks/get auth gate (E2E)', () => {
|
||||
|
||||
@ -29,6 +29,12 @@ export interface MountA2aRequestHandlerDeps {
|
||||
issuer: string;
|
||||
version: string;
|
||||
limits?: Partial<A2aLimits>;
|
||||
/**
|
||||
* 共有 limiter。サブシステムが REST カードルータと同一インスタンスを渡すことで、
|
||||
* 1 grant のレート予算が JSON-RPC と拡張カード両経路で共有される。未指定時は
|
||||
* `limits` から生成(後方互換: limiter を渡さない既存呼び出し・テスト向け)。
|
||||
*/
|
||||
limiter?: A2aLimiter;
|
||||
}
|
||||
|
||||
type CardDeps = { baseUrl: string; issuer: string; version: string };
|
||||
@ -42,8 +48,13 @@ type CardDeps = { baseUrl: string; issuer: string; version: string };
|
||||
* context.user が存在しない・isAuthenticated !== true の場合は A2AError.invalidRequest を投げ、
|
||||
* JsonRpcTransportHandler がそれを -32600 の JSON-RPC エラーレスポンスに変換する。
|
||||
*
|
||||
* sendMessage / sendMessageStream はゲートしない:executor.execute() が
|
||||
* principal のチェックを行い、unauthorized ならタスクを failed 状態で終了させる(既存の保護)。
|
||||
* sendMessage / sendMessageStream: limiter 注入時は execute() を呼ぶ前に ingress
|
||||
* ガバナンス(auth → grantId → payload → rate)をこの層で施行する。ここで拒否すれば
|
||||
* クリーンな JSON-RPC エラーになり、タスク行も監査行も一切永続化されない(SDK は
|
||||
* execute() がイベントを publish して初めて a2a_tasks に書き込むため)。concurrency と
|
||||
* skill-budget は予約ライフサイクルの都合で execute() 側に残すが、rate をここへ上げた
|
||||
* ことで両者の永続化される拒否も ratePerMinute に抑制される。scope 違反など principal
|
||||
* ベースの拒否は従来どおり execute() が failed タスクとして終了させる。
|
||||
*
|
||||
* Task 7: limiter が注入された場合、認証チェックの後に rate-limit と
|
||||
* resubscribe スロットキャップを適用する(auth → rate → slot の順序を保証)。
|
||||
@ -62,6 +73,45 @@ export class AuthGatedRequestHandler extends DefaultRequestHandler {
|
||||
this.limiter = limiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingress governance for message/send + message/stream, enforced BEFORE the SDK invokes
|
||||
* the executor. A denial here throws a clean JSON-RPC error and persists NOTHING — the SDK
|
||||
* only writes a task row once execute() publishes an event, and its own error-catch path
|
||||
* would persist a `failed` Task even for a thrown execute(). Enforcing rate + payload up
|
||||
* here removes the write-amplification flood vector. Mirrors the resubscribe gate:
|
||||
* auth → grantId (fail-closed) → payload → rate. Without a limiter (tests / metering off)
|
||||
* this is auth-only and send/stream fall through to execute() as before.
|
||||
*/
|
||||
private enforceIngress(params: { message?: unknown }, context?: ServerCallContext): void {
|
||||
this.requireAuthenticated(context);
|
||||
const grantId = this.meteredGrantId(context);
|
||||
if (!this.limiter) return; // metering off (e.g. tests without a limiter)
|
||||
const bytes = Buffer.byteLength(JSON.stringify(params.message ?? {}), 'utf8');
|
||||
if (bytes > this.limiter.limits.maxPayloadBytes) {
|
||||
throw A2AError.invalidRequest(
|
||||
`payload too large: ${bytes} > ${this.limiter.limits.maxPayloadBytes}`,
|
||||
);
|
||||
}
|
||||
if (!this.limiter.tryConsumeRate(grantId!)) {
|
||||
throw A2AError.invalidRequest('rate limit exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
override async sendMessage(params: Parameters<DefaultRequestHandler['sendMessage']>[0], context?: ServerCallContext) {
|
||||
this.enforceIngress(params, context);
|
||||
return super.sendMessage(params, context);
|
||||
}
|
||||
|
||||
override sendMessageStream(
|
||||
params: Parameters<DefaultRequestHandler['sendMessageStream']>[0],
|
||||
context?: ServerCallContext,
|
||||
): ReturnType<DefaultRequestHandler['sendMessageStream']> {
|
||||
// Synchronous gate before super returns the generator: a denial throws at the transport's
|
||||
// call site (before SSE headers) → clean JSON-RPC error, no eventBus, no execute(), no rows.
|
||||
this.enforceIngress(params, context);
|
||||
return super.sendMessageStream(params, context);
|
||||
}
|
||||
|
||||
override async getTask(params: Parameters<DefaultRequestHandler['getTask']>[0], context?: ServerCallContext) {
|
||||
this.requireAuthenticated(context);
|
||||
const grantId = this.meteredGrantId(context);
|
||||
@ -80,6 +130,20 @@ export class AuthGatedRequestHandler extends DefaultRequestHandler {
|
||||
return super.cancelTask(params, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 認証済み拡張カード RPC。scope 計算(DB 読み)を伴うため getTask 同様に
|
||||
* auth → grantId(fail-closed) → rate の順でメータリングする。REST 経路
|
||||
* (`GET /a2a/agent-card/extended`) の同等ゲートと対になる(両者は同一 limiter を共有)。
|
||||
*/
|
||||
override async getAuthenticatedExtendedAgentCard(context?: ServerCallContext) {
|
||||
this.requireAuthenticated(context);
|
||||
const grantId = this.meteredGrantId(context);
|
||||
if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) {
|
||||
throw A2AError.invalidRequest('rate limit exceeded');
|
||||
}
|
||||
return super.getAuthenticatedExtendedAgentCard(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 認証ゲートを同期的に実行してから super.resubscribe() を返す。
|
||||
* 同期 throw にすることで、JsonRpcTransportHandler がジェネレータを取得する前に
|
||||
@ -227,8 +291,8 @@ export function makeExtendedCardProvider(
|
||||
* 認証は makeA2aUserBuilder(UserBuilder)が担う。
|
||||
*/
|
||||
export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void {
|
||||
const limits = resolveA2aLimits(deps.limits);
|
||||
const limiter = new A2aLimiter(limits);
|
||||
const limiter = deps.limiter ?? new A2aLimiter(resolveA2aLimits(deps.limits));
|
||||
const limits = limiter.limits;
|
||||
const taskStore = new SqliteA2aTaskStore(deps.repo);
|
||||
const executor = new MaestroA2aExecutor(deps.repo, { limiter });
|
||||
const cardDeps: CardDeps = {
|
||||
|
||||
@ -34,7 +34,7 @@ const SPACE_FILES_RE = /^\/api\/local\/spaces\/([^/]+)\/files(?:\/|$|\?)/;
|
||||
* ordinary (non-admin) active user. The shape must match the Express.User
|
||||
* interface declared in src/bridge/auth.ts:
|
||||
* id, email, name, avatarUrl, role, status, orgIds,
|
||||
* defaultVisibility, defaultVisibilityOrgId
|
||||
* defaultVisibility, defaultVisibilityOrgId, hasLocalCredential
|
||||
*
|
||||
* viewerOf() reads req.user directly when authActive && req.user is set.
|
||||
* canManageSpace() passes when space.ownerId === user.id, so the caller must
|
||||
@ -51,6 +51,7 @@ function buildSyntheticViewer(userId: string): Express.User {
|
||||
orgIds: [],
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
hasLocalCredential: false, // token-scoped synthetic viewer, not a real credential holder
|
||||
} as Express.User;
|
||||
}
|
||||
|
||||
|
||||
@ -161,6 +161,9 @@ export function mountAuthPipeline(
|
||||
// is enforced inside pieces-api.ts handlers.
|
||||
app.use('/api/pieces', requireAuth);
|
||||
app.use('/api/usage', requireAuth);
|
||||
// LLM ワーカー選択 API(タスクへの pin 用): 秘密値は含まないが、未認証の
|
||||
// 列挙は不要なので他の /api/* と同様 requireAuth で揃える。
|
||||
app.use('/api/llm/workers', requireAuth);
|
||||
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
|
||||
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
|
||||
app.use('/api/calendar', requireAuth);
|
||||
|
||||
96
src/bridge/auth.has-local-credential.test.ts
Normal file
96
src/bridge/auth.has-local-credential.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* hasLocalCredential on the session user (issue #799): drives whether the
|
||||
* password-change UI (Settings → Preferences) is shown to a user. It's
|
||||
* computed by passport.deserializeUser (see auth.ts), so it must be verified
|
||||
* on a REAL subsequent request — not just the login response — through a
|
||||
* protected route.
|
||||
*
|
||||
* setupAuth ONCE per file (beforeAll), mirroring admin-api.local.test.ts:
|
||||
* passport.serializeUser/deserializeUser register onto a global stack and
|
||||
* don't replace, so calling setupAuth per-test would leave earlier tests'
|
||||
* deserializers (bound to already-closed repos) running first and erroring
|
||||
* out the whole chain. A dedicated file also keeps this isolated from
|
||||
* auth.local.test.ts's per-test setupAuth calls (same global passport
|
||||
* singleton within one file/worker).
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import express, { type Express } from 'express';
|
||||
import request from 'supertest';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { runMigrations } from '../db/migrate.js';
|
||||
import { setupAuth } from './auth.js';
|
||||
import type { AuthConfig } from '../config.js';
|
||||
|
||||
const AUTH: AuthConfig = {
|
||||
sessionSecret: 'test', sessionMaxAge: 3600_000, secureCookie: false,
|
||||
adminEmails: [], providers: {}, local: { enabled: true, allowSignup: true },
|
||||
};
|
||||
|
||||
describe('hasLocalCredential on the session user', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: Express;
|
||||
|
||||
beforeAll(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-haslocalcred-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
|
||||
app = express();
|
||||
const auth = setupAuth(repo, AUTH);
|
||||
app.use(auth.sessionMiddleware);
|
||||
app.use(auth.passportInit);
|
||||
app.use(auth.passportSession);
|
||||
app.use('/auth', auth.authRouter);
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
if (!req.isAuthenticated() || !req.user) { res.status(401).json({ error: 'unauthenticated' }); return; }
|
||||
res.json(req.user);
|
||||
});
|
||||
// Test-only login shortcut: bypasses password verification so a test can
|
||||
// put an arbitrary existing (e.g. OAuth-created) user id into the session
|
||||
// and exercise passport.deserializeUser on the next request — the same
|
||||
// code path a real OAuth callback's req.login would take.
|
||||
app.post('/test/login/:id', (req, res, next) => {
|
||||
req.login({ id: req.params.id } as Express.User, (err) => {
|
||||
if (err) { next(err); return; }
|
||||
res.sendStatus(204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
repo.close();
|
||||
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||
});
|
||||
|
||||
it('is true after a local login', async () => {
|
||||
repo.createLocalUser({ email: 'local-cred@x.com', password: 'pw12345678', role: 'user', status: 'active' });
|
||||
const agent = request.agent(app);
|
||||
await agent.post('/auth/local').type('form').send({ email: 'local-cred@x.com', password: 'pw12345678' });
|
||||
const res = await agent.get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasLocalCredential).toBe(true);
|
||||
// secret material must never leak onto the session user
|
||||
expect(res.body.password).toBeUndefined();
|
||||
expect(res.body.passwordHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is false for an OAuth-only account', async () => {
|
||||
const oauthUser = repo.findOrCreateUserByOAuth({
|
||||
provider: 'gitea',
|
||||
providerId: 'gitea-123',
|
||||
email: 'oauth@x.com',
|
||||
name: 'OAuth User',
|
||||
avatarUrl: undefined,
|
||||
});
|
||||
repo.updateUser(oauthUser.id, { status: 'active' });
|
||||
const agent = request.agent(app);
|
||||
await agent.post(`/test/login/${oauthUser.id}`);
|
||||
const res = await agent.get('/api/auth/me');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasLocalCredential).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -212,6 +212,13 @@ declare global {
|
||||
orgIds: string[];
|
||||
defaultVisibility: 'private' | 'org' | 'public';
|
||||
defaultVisibilityOrgId: string | null;
|
||||
/**
|
||||
* Whether this account has a local (email+password) credential set, as
|
||||
* opposed to being OAuth-only (Google/Gitea). Drives whether the
|
||||
* password-change UI (Settings → Preferences) is shown — never carries
|
||||
* the credential itself. See repo.hasLocalCredential.
|
||||
*/
|
||||
hasLocalCredential: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -513,6 +520,7 @@ async function handleOAuthCallback(
|
||||
orgIds: [],
|
||||
defaultVisibility: user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
|
||||
hasLocalCredential: repo.hasLocalCredential(user.id),
|
||||
};
|
||||
done(null, sessionUser);
|
||||
} catch (err) {
|
||||
@ -659,6 +667,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
orgIds,
|
||||
defaultVisibility: user.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: user.defaultVisibilityOrgId ?? null,
|
||||
hasLocalCredential: repo.hasLocalCredential(user.id),
|
||||
};
|
||||
done(null, sessionUser);
|
||||
} catch (err) {
|
||||
@ -771,6 +780,7 @@ function createAuthRouter(
|
||||
orgIds: resolveOrgIds(repo, u.id),
|
||||
defaultVisibility: u.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: u.defaultVisibilityOrgId ?? null,
|
||||
hasLocalCredential: repo.hasLocalCredential(u.id),
|
||||
});
|
||||
|
||||
const readCreds = (req: Request): { email: string; password: string } | null => {
|
||||
@ -937,6 +947,7 @@ export function setupAuth(
|
||||
orgIds: resolveOrgIds(repo, id),
|
||||
defaultVisibility: baseUser.defaultVisibility ?? 'private',
|
||||
defaultVisibilityOrgId: baseUser.defaultVisibilityOrgId ?? null,
|
||||
hasLocalCredential: repo.hasLocalCredential(id),
|
||||
};
|
||||
done(null, enriched);
|
||||
} catch (err) {
|
||||
|
||||
270
src/bridge/chat/chat-connector-bindings-api.test.ts
Normal file
270
src/bridge/chat/chat-connector-bindings-api.test.ts
Normal file
@ -0,0 +1,270 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js';
|
||||
|
||||
/**
|
||||
* Minimal admin gate mirroring requireAdmin's observable contract
|
||||
* (401 unauthenticated / 403 non-admin / next() for admin), following the
|
||||
* same pattern as mcp-api.test.ts and dashboard-api.auth-guard.test.ts —
|
||||
* real requireAdmin depends on passport's req.isAuthenticated() which needs
|
||||
* a full session harness this router-level test doesn't need.
|
||||
*/
|
||||
function adminGate(user: { id: string; role: 'admin' | 'user' } | null): express.RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (!user) { res.status(401).json({ error: 'Unauthorized' }); return; }
|
||||
(req as any).user = user;
|
||||
if (user.role !== 'admin') { res.status(403).json({ error: 'Forbidden' }); return; }
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function appWith(repo: Repository, user: { id: string; role: 'admin' | 'user' } | null = { id: 'admin1', role: 'admin' }) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/chat/bindings', adminGate(user), createChatConnectorBindingsAdminRouter(repo, true));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('chat connector bindings admin API', () => {
|
||||
let repo: Repository;
|
||||
let ownerId = '';
|
||||
let spaceId = '';
|
||||
let clientId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
repo = new Repository(':memory:');
|
||||
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
ownerId = owner.id;
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
clientId = `a2a_${randomUUID()}`;
|
||||
repo.createA2aClient({
|
||||
clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'],
|
||||
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||
keyFingerprint: null, status: 'active', createdBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => { repo.close(); });
|
||||
|
||||
function seedDelegation(opts: { grantedSpaceIds?: string[]; revokedAt?: string | null; grantId?: string | null } = {}): string {
|
||||
const id = randomUUID();
|
||||
repo.createA2aDelegation({
|
||||
id, userId: ownerId, clientId,
|
||||
grantId: opts.grantId === undefined ? randomUUID() : opts.grantId,
|
||||
grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId],
|
||||
grantedSkills: ['chat'],
|
||||
audience: null, expiresAt: null, revokedAt: opts.revokedAt ?? null,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
const validBody = (delegationId: string, overrides: Record<string, unknown> = {}) => ({
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
externalChannelId: 'C456',
|
||||
a2aDelegationId: delegationId,
|
||||
botCredentials: { signingSecret: 'sec', botToken: 'xoxb-test' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── admin gate ─────────────────────────────────────────────────────────
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const app = appWith(repo, null);
|
||||
const res = await request(app).get('/api/admin/chat/bindings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin authenticated requests with 403', async () => {
|
||||
const app = appWith(repo, { id: 'u1', role: 'user' });
|
||||
const res = await request(app).get('/api/admin/chat/bindings');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows admin requests through to the router', async () => {
|
||||
const app = appWith(repo, { id: 'admin1', role: 'admin' });
|
||||
const res = await request(app).get('/api/admin/chat/bindings');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bindings).toEqual([]);
|
||||
});
|
||||
|
||||
// ── create ─────────────────────────────────────────────────────────────
|
||||
it('creates a binding for a delegation granting exactly one space', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.spaceId).toBe(spaceId);
|
||||
expect(res.body.a2aDelegationId).toBe(delegationId);
|
||||
expect(res.body.status).toBe('active');
|
||||
// Credentials must never appear in the response.
|
||||
expect(res.body.botCredentialsEnc).toBeUndefined();
|
||||
expect(res.body.botCredentials).toBeUndefined();
|
||||
expect(JSON.stringify(res.body)).not.toContain('xoxb-test');
|
||||
});
|
||||
|
||||
it('rejects a delegation granting zero spaces (1 delegation = 1 space enforcement)', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation({ grantedSpaceIds: [] });
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/exactly one space/);
|
||||
});
|
||||
|
||||
it('rejects a delegation granting more than one space (1 delegation = 1 space enforcement)', async () => {
|
||||
const app = appWith(repo);
|
||||
const space2 = await repo.createSpace({ kind: 'case', title: 'Case 2', ownerId, visibility: 'private' });
|
||||
const delegationId = seedDelegation({ grantedSpaceIds: [spaceId, space2.id] });
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/exactly one space/);
|
||||
});
|
||||
|
||||
it('derives spaceId from the delegation, ignoring any client-supplied spaceId', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings')
|
||||
.send(validBody(delegationId, { spaceId: 'attacker-supplied-space' }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.spaceId).toBe(spaceId);
|
||||
});
|
||||
|
||||
it('rejects a revoked delegation', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation({ revokedAt: new Date().toISOString() });
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a delegation with no grant_id', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation({ grantId: null });
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an unknown delegation id', async () => {
|
||||
const app = appWith(repo);
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody('nonexistent'));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an unknown platform', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId, { platform: 'discord' }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a request missing botCredentials', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings')
|
||||
.send(validBody(delegationId, { botCredentials: undefined }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a duplicate (platform, team, channel) binding with 409', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId1 = seedDelegation();
|
||||
await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId1));
|
||||
const delegationId2 = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId2));
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('sets created_by to the authenticated admin, not any client-supplied value', async () => {
|
||||
const app = appWith(repo, { id: 'admin-xyz', role: 'admin' });
|
||||
const delegationId = seedDelegation();
|
||||
const res = await request(app).post('/api/admin/chat/bindings')
|
||||
.send(validBody(delegationId, { createdBy: 'attacker' }));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.createdBy).toBe('admin-xyz');
|
||||
});
|
||||
|
||||
// ── read ───────────────────────────────────────────────────────────────
|
||||
it('GET /:id returns 404 for an unknown id', async () => {
|
||||
const app = appWith(repo);
|
||||
const res = await request(app).get('/api/admin/chat/bindings/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET list never includes bot_credentials_enc', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
const res = await request(app).get('/api/admin/chat/bindings');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(JSON.stringify(res.body)).not.toContain('xoxb-test');
|
||||
});
|
||||
|
||||
// ── update ─────────────────────────────────────────────────────────────
|
||||
it('PATCH disables and re-enables a binding', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
const id = created.body.id;
|
||||
|
||||
const disabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'disabled' });
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(disabled.body.status).toBe('disabled');
|
||||
|
||||
const enabled = await request(app).patch(`/api/admin/chat/bindings/${id}`).send({ status: 'active' });
|
||||
expect(enabled.status).toBe(200);
|
||||
expect(enabled.body.status).toBe('active');
|
||||
});
|
||||
|
||||
it('PATCH re-encrypts credentials on re-entry and never echoes them back', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
const id = created.body.id;
|
||||
|
||||
const res = await request(app).patch(`/api/admin/chat/bindings/${id}`)
|
||||
.send({ botCredentials: { signingSecret: 'new-sec', botToken: 'xoxb-new-token' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(JSON.stringify(res.body)).not.toContain('xoxb-new-token');
|
||||
// Verify the stored ciphertext actually changed.
|
||||
const stored = repo.getChatConnectorBindingById(id)!;
|
||||
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||
const { decrypt } = await import('../../mcp/crypto.js');
|
||||
expect(JSON.parse(decrypt(stored.botCredentialsEnc, key)).botToken).toBe('xoxb-new-token');
|
||||
});
|
||||
|
||||
it('PATCH with no updatable fields returns 400', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
const res = await request(app).patch(`/api/admin/chat/bindings/${created.body.id}`).send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PATCH on an unknown id returns 404', async () => {
|
||||
const app = appWith(repo);
|
||||
const res = await request(app).patch('/api/admin/chat/bindings/nonexistent').send({ status: 'disabled' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ── delete ─────────────────────────────────────────────────────────────
|
||||
it('DELETE removes a binding', async () => {
|
||||
const app = appWith(repo);
|
||||
const delegationId = seedDelegation();
|
||||
const created = await request(app).post('/api/admin/chat/bindings').send(validBody(delegationId));
|
||||
const id = created.body.id;
|
||||
const res = await request(app).delete(`/api/admin/chat/bindings/${id}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(repo.getChatConnectorBindingById(id)).toBeNull();
|
||||
});
|
||||
|
||||
it('DELETE on an unknown id returns 404', async () => {
|
||||
const app = appWith(repo);
|
||||
const res = await request(app).delete('/api/admin/chat/bindings/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
197
src/bridge/chat/chat-connector-bindings-api.ts
Normal file
197
src/bridge/chat/chat-connector-bindings-api.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* chat-connector-bindings-api: admin CRUD for chat_connector_bindings
|
||||
* (issue #801, PR1 — Slack MVP). No UI wizard yet; this is the manual-seed
|
||||
* surface an admin (or a script) calls directly. requireAdmin is applied by
|
||||
* the mount call site (see chat-subsystem.ts), mirroring a2a-clients-admin-api.ts.
|
||||
*
|
||||
* SECURITY:
|
||||
* - The response DTO (toPublicBinding) NEVER includes bot_credentials_enc
|
||||
* or a decrypted credential. Once saved, credentials are write-only —
|
||||
* re-entering them means a PATCH with a fresh { botCredentials } object.
|
||||
* - 1 delegation = 1 space is enforced at create time: the referenced
|
||||
* a2a_delegation must have exactly one granted space id, and that id
|
||||
* (not any client-supplied value) becomes the binding's space_id. This
|
||||
* keeps the chat channel's effective space deterministic (skill-router.ts
|
||||
* always resolves `effectiveSpaceIds[0]`).
|
||||
* - "acting user" for job execution is always the delegation's own user_id
|
||||
* (the person who consented via the A2A OAuth flow) — never client input.
|
||||
* `created_by` on the binding row is the authenticated admin, also never
|
||||
* client input.
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import type { Repository, ChatConnectorBindingRecord } from '../../db/repository.js';
|
||||
import { isDelegationLive } from '../a2a/delegation.js';
|
||||
import { encrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
const KNOWN_PLATFORMS = ['slack'] as const;
|
||||
const MAX_ID_LENGTH = 256;
|
||||
|
||||
function toPublicBinding(b: ChatConnectorBindingRecord) {
|
||||
return {
|
||||
id: b.id,
|
||||
platform: b.platform,
|
||||
externalWorkspaceId: b.externalWorkspaceId,
|
||||
externalChannelId: b.externalChannelId,
|
||||
spaceId: b.spaceId,
|
||||
a2aClientId: b.a2aClientId,
|
||||
a2aDelegationId: b.a2aDelegationId,
|
||||
status: b.status,
|
||||
createdBy: b.createdBy,
|
||||
createdAt: b.createdAt,
|
||||
updatedAt: b.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(v: unknown, max = MAX_ID_LENGTH): v is string {
|
||||
return typeof v === 'string' && v.trim().length > 0 && v.length <= max;
|
||||
}
|
||||
|
||||
export function createChatConnectorBindingsAdminRouter(repo: Repository, _authActive: boolean): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
res.json({ bindings: repo.listChatConnectorBindings().map(toPublicBinding) });
|
||||
});
|
||||
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||
res.json(toPublicBinding(binding));
|
||||
});
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const body = req.body ?? {};
|
||||
const rawPlatform = body.platform;
|
||||
if (typeof rawPlatform !== 'string' || !(KNOWN_PLATFORMS as readonly string[]).includes(rawPlatform)) {
|
||||
res.status(400).json({ error: `platform must be one of: ${KNOWN_PLATFORMS.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const platform = rawPlatform as (typeof KNOWN_PLATFORMS)[number];
|
||||
if (!isNonEmptyString(body.externalWorkspaceId) || !isNonEmptyString(body.externalChannelId)) {
|
||||
res.status(400).json({ error: 'externalWorkspaceId and externalChannelId are required' });
|
||||
return;
|
||||
}
|
||||
if (!isNonEmptyString(body.a2aDelegationId)) {
|
||||
res.status(400).json({ error: 'a2aDelegationId is required' });
|
||||
return;
|
||||
}
|
||||
const creds = body.botCredentials;
|
||||
if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) {
|
||||
res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const delegation = repo.getA2aDelegationById(body.a2aDelegationId);
|
||||
if (!delegation || !delegation.grantId) {
|
||||
res.status(400).json({ error: 'a2aDelegationId does not reference a live delegation with an active grant' });
|
||||
return;
|
||||
}
|
||||
if (!isDelegationLive(delegation, new Date().toISOString())) {
|
||||
res.status(400).json({ error: 'delegation is expired or revoked' });
|
||||
return;
|
||||
}
|
||||
// 1 delegation = 1 space, enforced (not client-suppliable).
|
||||
if (delegation.grantedSpaceIds.length !== 1) {
|
||||
res.status(400).json({ error: 'delegation must grant exactly one space to back a chat binding' });
|
||||
return;
|
||||
}
|
||||
const spaceId = delegation.grantedSpaceIds[0];
|
||||
|
||||
let botCredentialsEnc: Buffer;
|
||||
try {
|
||||
botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv());
|
||||
} catch (err) {
|
||||
logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`);
|
||||
res.status(503).json({ error: 'credential encryption is not configured on this server' });
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = (req.user as Express.User | undefined)?.id ?? null;
|
||||
try {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform,
|
||||
externalWorkspaceId: body.externalWorkspaceId,
|
||||
externalChannelId: body.externalChannelId,
|
||||
spaceId,
|
||||
a2aClientId: delegation.clientId,
|
||||
a2aDelegationId: delegation.id,
|
||||
a2aGrantId: delegation.grantId,
|
||||
botCredentialsEnc,
|
||||
createdBy: admin,
|
||||
});
|
||||
logger.info(`[chat-connector-bindings-api] created binding=${created.id} platform=${platform} space=${spaceId} by=${admin}`);
|
||||
res.status(201).json(toPublicBinding(created));
|
||||
} catch (err) {
|
||||
// UNIQUE constraint on (platform, external_workspace_id, external_channel_id)
|
||||
const msg = (err as Error).message ?? '';
|
||||
if (/UNIQUE constraint failed/.test(msg)) {
|
||||
res.status(409).json({ error: 'a binding already exists for this platform/workspace/channel' });
|
||||
return;
|
||||
}
|
||||
logger.error(`[chat-connector-bindings-api] POST failed: ${msg}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH — update status (enable/disable) and/or re-enter credentials.
|
||||
router.patch('/:id', (req: Request, res: Response) => {
|
||||
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||
|
||||
const body = req.body ?? {};
|
||||
const patch: Parameters<typeof repo.updateChatConnectorBinding>[1] = {};
|
||||
|
||||
if (body.status !== undefined) {
|
||||
if (body.status !== 'active' && body.status !== 'disabled') {
|
||||
res.status(400).json({ error: "status must be 'active' or 'disabled'" });
|
||||
return;
|
||||
}
|
||||
patch.status = body.status;
|
||||
}
|
||||
if (body.botCredentials !== undefined) {
|
||||
const creds = body.botCredentials;
|
||||
if (!creds || !isNonEmptyString(creds.signingSecret) || !isNonEmptyString(creds.botToken)) {
|
||||
res.status(400).json({ error: 'botCredentials.signingSecret and botCredentials.botToken are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
patch.botCredentialsEnc = encrypt(JSON.stringify({ signingSecret: creds.signingSecret, botToken: creds.botToken }), loadKeyFromEnv());
|
||||
} catch (err) {
|
||||
logger.error(`[chat-connector-bindings-api] encryption unavailable: ${(err as Error).message}`);
|
||||
res.status(503).json({ error: 'credential encryption is not configured on this server' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
res.status(400).json({ error: 'no updatable fields provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = repo.updateChatConnectorBinding(binding.id, patch);
|
||||
if (!updated) { res.status(404).json({ error: 'not found' }); return; }
|
||||
logger.info(`[chat-connector-bindings-api] updated binding=${binding.id} fields=${Object.keys(patch).join(',')}`);
|
||||
res.json(toPublicBinding(updated));
|
||||
} catch (err) {
|
||||
logger.error(`[chat-connector-bindings-api] PATCH failed binding=${binding.id}: ${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const binding = repo.getChatConnectorBindingById(req.params.id);
|
||||
if (!binding) { res.status(404).json({ error: 'not found' }); return; }
|
||||
try {
|
||||
repo.deleteChatConnectorBinding(binding.id);
|
||||
logger.info(`[chat-connector-bindings-api] deleted binding=${binding.id}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error(`[chat-connector-bindings-api] DELETE failed binding=${binding.id}: ${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
290
src/bridge/chat/chat-connector-service.test.ts
Normal file
290
src/bridge/chat/chat-connector-service.test.ts
Normal file
@ -0,0 +1,290 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* chat-connector-service tests. Uses a real (temp-file) Repository — matching
|
||||
* webhook-service.test.ts's convention — plus mocked executor/chat-client
|
||||
* factories so we exercise binding resolution, delegation liveness,
|
||||
* governance, and reply-building without a real MaestroA2aExecutor job run
|
||||
* (that path is covered by executor.test.ts).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import type { ChatConnectorBindingRecord } from '../../db/repository.js';
|
||||
import { encrypt } from '../../mcp/crypto.js';
|
||||
import { handleSlackMention, type ChatConnectorServiceDeps } from './chat-connector-service.js';
|
||||
import type { ChatMentionEvent } from './chat-connector-types.js';
|
||||
import { A2aLimiter, DEFAULT_A2A_LIMITS } from '../a2a/limiter.js';
|
||||
|
||||
describe('chat-connector-service: handleSlackMention', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let ownerId = '';
|
||||
let spaceId = '';
|
||||
let clientId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-svc-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
ownerId = owner.id;
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
clientId = `a2a_${randomUUID()}`;
|
||||
repo.createA2aClient({
|
||||
clientId, name: 'Test Slack App', redirectUris: ['https://example.com/cb'],
|
||||
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||
keyFingerprint: null, status: 'active', createdBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedDelegation(opts: { grantId?: string | null; grantedSpaceIds?: string[]; revokedAt?: string | null; expiresAt?: string | null } = {}): string {
|
||||
const id = randomUUID();
|
||||
repo.createA2aDelegation({
|
||||
id, userId: ownerId, clientId,
|
||||
grantId: opts.grantId === undefined ? randomUUID() : opts.grantId,
|
||||
grantedSpaceIds: opts.grantedSpaceIds ?? [spaceId],
|
||||
grantedSkills: ['chat'],
|
||||
audience: null,
|
||||
expiresAt: opts.expiresAt ?? null,
|
||||
revokedAt: opts.revokedAt ?? null,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
function encryptCreds(): Buffer {
|
||||
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||
return encrypt(JSON.stringify({ signingSecret: 'sec', botToken: 'xoxb-test' }), key);
|
||||
}
|
||||
|
||||
function seedBinding(delegationId: string, grantId: string, opts: { status?: 'active' | 'disabled'; team?: string; channel?: string } = {}): ChatConnectorBindingRecord {
|
||||
return repo.createChatConnectorBinding({
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: opts.team ?? 'T123',
|
||||
externalChannelId: opts.channel ?? 'C456',
|
||||
spaceId,
|
||||
a2aClientId: clientId,
|
||||
a2aDelegationId: delegationId,
|
||||
a2aGrantId: grantId,
|
||||
botCredentialsEnc: encryptCreds(),
|
||||
createdBy: ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<ChatMentionEvent> = {}): ChatMentionEvent {
|
||||
return {
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
externalChannelId: 'C456',
|
||||
text: 'summarize the thread',
|
||||
messageTs: '1700000000.000100',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatClientFactory() {
|
||||
const postMessage = vi.fn().mockResolvedValue(true);
|
||||
const factory = () => ({ postMessage });
|
||||
return { factory, postMessage };
|
||||
}
|
||||
|
||||
it('ignores a mention when no active binding exists (fail-closed, no reply)', async () => {
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a mention when the binding is disabled', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId, { status: 'active' });
|
||||
// Immediately disable it.
|
||||
const binding = repo.findActiveChatConnectorBinding('slack', 'T123', 'C456')!;
|
||||
repo.updateChatConnectorBinding(binding.id, { status: 'disabled' });
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a mention when the delegation has been revoked (fail-closed)', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId, revokedAt: new Date().toISOString() });
|
||||
seedBinding(delegationId, grantId);
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a mention when the delegation has expired (fail-closed)', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId, expiresAt: new Date(Date.now() - 60_000).toISOString() });
|
||||
seedBinding(delegationId, grantId);
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a mention with an empty (whitespace-only) text', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent({ text: ' ' }), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps);
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs a mention through the executor and replies with the local task result comment + link', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
|
||||
const localTask = await repo.createLocalTask({
|
||||
title: 'from slack', body: 'summarize the thread', pieceName: 'chat',
|
||||
ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||
});
|
||||
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\nHere is the summary.', 'result');
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executeSpy = vi.fn(async (_ctx: unknown, bus: any) => {
|
||||
bus.publish({
|
||||
kind: 'task', id: 'a2a-task-1', contextId: 'ctx-1',
|
||||
status: { state: 'submitted', timestamp: new Date().toISOString() },
|
||||
metadata: { maestroLocalTaskId: localTask.id, maestroJobId: 'job-1' },
|
||||
});
|
||||
bus.publish({
|
||||
kind: 'status-update', taskId: 'a2a-task-1', contextId: 'ctx-1',
|
||||
status: { state: 'completed', timestamp: new Date().toISOString() }, final: true,
|
||||
});
|
||||
bus.finished();
|
||||
});
|
||||
const executorFactory = vi.fn(() => ({ execute: executeSpy }));
|
||||
|
||||
await handleSlackMention(makeEvent(), {
|
||||
repo, chatClientFactory: factory, executorFactory,
|
||||
publicBaseUrl: 'https://maestro.example.com',
|
||||
} as unknown as ChatConnectorServiceDeps);
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||
// requestContext carries the in-process A2A principal built from the delegation row.
|
||||
const [ctxArg] = executeSpy.mock.calls[0];
|
||||
expect((ctxArg as any).context.user.a2aPrincipal.actingUserId).toBe(ownerId);
|
||||
expect((ctxArg as any).context.user.a2aPrincipal.grantId).toBe(grantId);
|
||||
expect((ctxArg as any).userMessage.parts[0].text).toBe('summarize the thread');
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
const [replyArgs] = postMessage.mock.calls[0];
|
||||
expect(replyArgs.channel).toBe('C456');
|
||||
expect(replyArgs.threadTs).toBe('1700000000.000100');
|
||||
expect(replyArgs.text).toContain('Here is the summary.');
|
||||
expect(replyArgs.text).toContain(`space=${spaceId}`);
|
||||
expect(replyArgs.text).toContain(`chat=${localTask.id}`);
|
||||
});
|
||||
|
||||
it('replies with the task-detail link using threadTs (not messageTs) when the mention was inside a thread', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
const localTask = await repo.createLocalTask({
|
||||
title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||
});
|
||||
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ 完了\n\ndone', 'result');
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn(() => ({
|
||||
execute: async (_ctx: unknown, bus: any) => {
|
||||
bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } });
|
||||
bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true });
|
||||
bus.finished();
|
||||
},
|
||||
}));
|
||||
|
||||
await handleSlackMention(makeEvent({ threadTs: '1699999999.000001', messageTs: '1700000000.000100' }), {
|
||||
repo, chatClientFactory: factory, executorFactory,
|
||||
} as unknown as ChatConnectorServiceDeps);
|
||||
|
||||
expect(postMessage.mock.calls[0][0].threadTs).toBe('1699999999.000001');
|
||||
});
|
||||
|
||||
it('governance: rejects an oversized mention without invoking the executor', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, maxPayloadBytes: 8 });
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await handleSlackMention(makeEvent({ text: 'this text is definitely longer than 8 bytes' }), {
|
||||
repo, chatClientFactory: factory, executorFactory, limiter,
|
||||
} as unknown as ChatConnectorServiceDeps);
|
||||
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(postMessage.mock.calls[0][0].text).toContain('長すぎます');
|
||||
});
|
||||
|
||||
it('governance: reuses the shared A2aLimiter so a second mention from the same delegation within the rate window is rejected', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
// ratePerMinute: 1 — the very next call from the same grant must be throttled.
|
||||
const limiter = new A2aLimiter({ ...DEFAULT_A2A_LIMITS, ratePerMinute: 1 });
|
||||
|
||||
const localTask = await repo.createLocalTask({
|
||||
title: 't', body: 'x', pieceName: 'chat', ownerId, spaceId, visibility: 'private', workspaceMode: 'persistent',
|
||||
});
|
||||
await repo.addLocalTaskComment(localTask.id, 'agent', '✅ done', 'result');
|
||||
const executorFactory = vi.fn(() => ({
|
||||
execute: async (_ctx: unknown, bus: any) => {
|
||||
bus.publish({ kind: 'task', metadata: { maestroLocalTaskId: localTask.id }, status: { state: 'submitted' } });
|
||||
bus.publish({ kind: 'status-update', status: { state: 'completed' }, final: true });
|
||||
bus.finished();
|
||||
},
|
||||
}));
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps);
|
||||
await handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory, limiter } as unknown as ChatConnectorServiceDeps);
|
||||
|
||||
expect(executorFactory).toHaveBeenCalledTimes(1); // second call was rate-limited before reaching the executor
|
||||
expect(postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(postMessage.mock.calls[1][0].text).toContain('多すぎます');
|
||||
});
|
||||
|
||||
it('fails closed (no reply, no throw) when bot credentials cannot be decrypted', async () => {
|
||||
const grantId = randomUUID();
|
||||
const delegationId = seedDelegation({ grantId });
|
||||
seedBinding(delegationId, grantId);
|
||||
// Swap the encryption key after the binding was created — decrypt() will now fail (auth tag mismatch).
|
||||
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
|
||||
const { factory, postMessage } = makeChatClientFactory();
|
||||
const executorFactory = vi.fn();
|
||||
await expect(
|
||||
handleSlackMention(makeEvent(), { repo, chatClientFactory: factory, executorFactory } as unknown as ChatConnectorServiceDeps),
|
||||
).resolves.not.toThrow();
|
||||
expect(executorFactory).not.toHaveBeenCalled();
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
266
src/bridge/chat/chat-connector-service.ts
Normal file
266
src/bridge/chat/chat-connector-service.ts
Normal file
@ -0,0 +1,266 @@
|
||||
/**
|
||||
* chat-connector-service: mention → binding lookup → in-process A2A principal
|
||||
* → MaestroA2aExecutor.execute() → reply (issue #801, PR1 — Slack MVP).
|
||||
*
|
||||
* This module is the single place that bridges an external chat mention into
|
||||
* the existing A2A execution path. It deliberately reuses, rather than
|
||||
* reimplements:
|
||||
* - MaestroA2aExecutor.execute() (src/bridge/a2a/executor.ts) for job
|
||||
* creation, polling, and terminal-state handling.
|
||||
* - computeEffectiveScope / isDelegationLive (src/bridge/a2a/delegation.ts)
|
||||
* for the AND-intersected authorization scope and fail-closed revocation
|
||||
* checks — invoked transitively by the executor.
|
||||
* - A2aLimiter (src/bridge/a2a/limiter.ts) for governance. Because this
|
||||
* path bypasses the JSON-RPC AuthGatedRequestHandler (request-handler.ts),
|
||||
* which normally enforces payload-size + rate-limit checks BEFORE calling
|
||||
* execute(), this module replicates that same ingress gate explicitly
|
||||
* (see `enforceIngress` below) using the identical A2aLimiter instance —
|
||||
* so rate/concurrency/skill-budget counters are shared across the A2A
|
||||
* JSON-RPC path and the chat path for the same grant.
|
||||
* - local_task_comments 'result'/'ask' rows (via Repository.getLatestResultComment)
|
||||
* for the reply summary, written by LocalProgressReporter.reportFinalResult /
|
||||
* reportAsk — no new result-reporting path is introduced.
|
||||
*
|
||||
* fail-closed points (all silently ignore rather than error, per the issue's
|
||||
* "existing binding" model — an attacker probing channel/team ids should not
|
||||
* learn whether a binding exists):
|
||||
* - no active binding for (platform, team, channel)
|
||||
* - delegation missing / revoked / expired / no grant_id
|
||||
* - bot credential decryption failure
|
||||
* - payload too large / rate limit exceeded (replies with a short notice —
|
||||
* these are legitimate-binding-holder-visible failures, not enumeration risks)
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Repository, A2aDelegationRow } from '../../db/repository.js';
|
||||
import { isDelegationLive } from '../a2a/delegation.js';
|
||||
import type { A2aPrincipal } from '../a2a/token-auth.js';
|
||||
import { MaestroA2aExecutor } from '../a2a/executor.js';
|
||||
import { A2aLimiter } from '../a2a/limiter.js';
|
||||
import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||
import { SlackChatClient } from './slack-adapter.js';
|
||||
import type { ChatMentionEvent, SlackBotCredentials } from './chat-connector-types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
const MAX_REPLY_CHARS = 800;
|
||||
|
||||
export interface ChatConnectorServiceDeps {
|
||||
repo: Repository;
|
||||
/** Shared with the A2A JSON-RPC path (see a2a-subsystem.ts) so governance
|
||||
* counters are keyed per-grant regardless of entry point. Omit only in
|
||||
* tests that don't care about governance. */
|
||||
limiter?: A2aLimiter;
|
||||
/** Absolute base URL for task-detail links in replies. Omit to send no link. */
|
||||
publicBaseUrl?: string;
|
||||
now?: () => string;
|
||||
/** Test seam: inject a fake executor instead of a real MaestroA2aExecutor. */
|
||||
executorFactory?: (deps: { repo: Repository; limiter?: A2aLimiter }) => { execute: MaestroA2aExecutor['execute'] };
|
||||
/** Test seam: inject a fake Slack client instead of a real network call. */
|
||||
chatClientFactory?: (credentials: SlackBotCredentials) => Pick<SlackChatClient, 'postMessage'>;
|
||||
}
|
||||
|
||||
/** Minimal ExecutionEventBus — collects published events and resolves once execute() calls finished(). */
|
||||
function makeCollectingEventBus() {
|
||||
const events: Array<Record<string, any>> = [];
|
||||
let resolveFinished!: () => void;
|
||||
const finishedPromise = new Promise<void>((resolve) => { resolveFinished = resolve; });
|
||||
const bus = {
|
||||
publish(event: Record<string, any>) { events.push(event); },
|
||||
finished() { resolveFinished(); },
|
||||
on() { return bus; },
|
||||
off() { return bus; },
|
||||
once() { return bus; },
|
||||
removeAllListeners() { return bus; },
|
||||
};
|
||||
return { bus, events, finishedPromise };
|
||||
}
|
||||
|
||||
function extractText(message: any): string | undefined {
|
||||
const part = message?.parts?.find((p: any) => p.kind === 'text');
|
||||
return typeof part?.text === 'string' ? part.text : undefined;
|
||||
}
|
||||
|
||||
interface ExecutorOutcome {
|
||||
state: string;
|
||||
message?: string;
|
||||
localTaskId?: number;
|
||||
}
|
||||
|
||||
function extractOutcome(events: Array<Record<string, any>>): ExecutorOutcome {
|
||||
let localTaskId: number | undefined;
|
||||
let state = 'unknown';
|
||||
let message: string | undefined;
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'task') {
|
||||
if (ev.metadata?.maestroLocalTaskId != null) localTaskId = ev.metadata.maestroLocalTaskId;
|
||||
}
|
||||
if (ev.kind === 'task' || ev.kind === 'status-update') {
|
||||
if (ev.status?.state) {
|
||||
state = ev.status.state;
|
||||
const text = extractText(ev.status.message);
|
||||
if (text) message = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { state, message, localTaskId };
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= max) return trimmed;
|
||||
return trimmed.slice(0, max) + '…(省略)';
|
||||
}
|
||||
|
||||
async function buildReply(outcome: ExecutorOutcome, repo: Repository, spaceId: string, publicBaseUrl?: string): Promise<string> {
|
||||
const link = (publicBaseUrl && outcome.localTaskId != null)
|
||||
? `\n${publicBaseUrl.replace(/\/$/, '')}/?page=spaces&space=${encodeURIComponent(spaceId)}&chat=${outcome.localTaskId}`
|
||||
: '';
|
||||
|
||||
if (outcome.localTaskId != null) {
|
||||
let resultComment: { body: string; kind: string } | null = null;
|
||||
try {
|
||||
resultComment = await repo.getLatestResultComment(outcome.localTaskId);
|
||||
} catch (err) {
|
||||
logger.warn(`[chat-connector] getLatestResultComment failed taskId=${outcome.localTaskId}: ${err}`);
|
||||
}
|
||||
if (resultComment) {
|
||||
return `${truncate(resultComment.body, MAX_REPLY_CHARS)}${link}`;
|
||||
}
|
||||
if (outcome.message) return `${truncate(outcome.message, MAX_REPLY_CHARS)}${link}`;
|
||||
return `タスクを実行しました。${link}`;
|
||||
}
|
||||
|
||||
// No local task was ever created (denied before task creation, e.g. out-of-scope or governance reject).
|
||||
return outcome.message ? `⚠️ ${truncate(outcome.message, MAX_REPLY_CHARS)}` : '⚠️ リクエストを処理できませんでした。';
|
||||
}
|
||||
|
||||
function buildPrincipal(delegation: A2aDelegationRow): A2aPrincipal {
|
||||
return {
|
||||
actingUserId: delegation.userId,
|
||||
clientId: delegation.clientId,
|
||||
grantId: delegation.grantId as string,
|
||||
delegation: {
|
||||
id: delegation.id,
|
||||
userId: delegation.userId,
|
||||
clientId: delegation.clientId,
|
||||
grantId: delegation.grantId as string,
|
||||
grantedSpaceIds: delegation.grantedSpaceIds,
|
||||
grantedSkills: delegation.grantedSkills,
|
||||
expiresAt: delegation.expiresAt,
|
||||
revokedAt: delegation.revokedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles one inbound Slack app_mention: resolves the binding, builds an
|
||||
* in-process A2A principal from the bound delegation, runs the message
|
||||
* through MaestroA2aExecutor, and posts the result back to Slack.
|
||||
*
|
||||
* Never throws — all failure paths are logged and either silently ignored
|
||||
* (fail-closed authorization gaps) or answered with a short notice
|
||||
* (governance gaps visible to a legitimate binding holder).
|
||||
*/
|
||||
export async function handleSlackMention(event: ChatMentionEvent, deps: ChatConnectorServiceDeps): Promise<void> {
|
||||
const { repo, limiter, publicBaseUrl } = deps;
|
||||
const now = deps.now ?? (() => new Date().toISOString());
|
||||
|
||||
const binding = repo.findActiveChatConnectorBinding(event.platform, event.externalWorkspaceId, event.externalChannelId);
|
||||
if (!binding) {
|
||||
logger.info(`[chat-connector] no active binding platform=${event.platform} team=${event.externalWorkspaceId} channel=${event.externalChannelId} — ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
const delegation = repo.getA2aDelegationById(binding.a2aDelegationId);
|
||||
const nowIso = now();
|
||||
if (!delegation || !delegation.grantId || !isDelegationLive(delegation, nowIso)) {
|
||||
logger.info(`[chat-connector] delegation not live binding=${binding.id} — ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.text.trim()) {
|
||||
logger.info(`[chat-connector] empty mention text binding=${binding.id} — ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
let credentials: SlackBotCredentials;
|
||||
try {
|
||||
const plaintext = decrypt(binding.botCredentialsEnc, loadKeyFromEnv());
|
||||
credentials = JSON.parse(plaintext) as SlackBotCredentials;
|
||||
} catch (err) {
|
||||
logger.warn(`[chat-connector] failed to decrypt bot credentials binding=${binding.id}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.chatClientFactory ? deps.chatClientFactory(credentials) : new SlackChatClient(credentials);
|
||||
const threadTs = event.threadTs ?? event.messageTs;
|
||||
const reply = (text: string) => client.postMessage({ channel: event.externalChannelId, text, threadTs }).catch((err) => {
|
||||
logger.warn(`[chat-connector] reply post failed binding=${binding.id}: ${err}`);
|
||||
});
|
||||
|
||||
const principal = buildPrincipal(delegation);
|
||||
|
||||
// Governance: replicate request-handler.ts's enforceIngress (payload size + rate)
|
||||
// using the SAME A2aLimiter instance passed in from a2a-subsystem.ts, so this
|
||||
// path shares budget/counters with the JSON-RPC A2A path for the same grant.
|
||||
// Concurrency + skill-budget are enforced inside MaestroA2aExecutor.execute()
|
||||
// itself (unchanged) — only rate + payload need to be replicated here because
|
||||
// they normally live in the JSON-RPC ingress layer this path bypasses.
|
||||
if (limiter) {
|
||||
const bytes = Buffer.byteLength(event.text, 'utf8');
|
||||
if (bytes > limiter.limits.maxPayloadBytes) {
|
||||
logger.info(`[chat-connector] payload too large binding=${binding.id} bytes=${bytes}`);
|
||||
await reply('⚠️ メッセージが長すぎます。短くしてもう一度お試しください。');
|
||||
return;
|
||||
}
|
||||
if (!limiter.tryConsumeRate(principal.grantId)) {
|
||||
logger.info(`[chat-connector] rate limit exceeded binding=${binding.id} grant=${principal.grantId}`);
|
||||
await reply('⚠️ リクエストが多すぎます。しばらく待ってから再度お試しください。');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const executor = deps.executorFactory
|
||||
? deps.executorFactory({ repo, limiter })
|
||||
: new MaestroA2aExecutor(repo, { limiter, pollMs: 1000 });
|
||||
|
||||
const taskId = randomUUID();
|
||||
const contextId = randomUUID();
|
||||
const requestContext = {
|
||||
taskId,
|
||||
contextId,
|
||||
userMessage: {
|
||||
kind: 'message',
|
||||
messageId: randomUUID(),
|
||||
role: 'user',
|
||||
parts: [{ kind: 'text', text: event.text }],
|
||||
contextId,
|
||||
metadata: {},
|
||||
},
|
||||
context: { user: { a2aPrincipal: principal, isAuthenticated: true, userName: principal.actingUserId } },
|
||||
} as unknown as Parameters<MaestroA2aExecutor['execute']>[0];
|
||||
|
||||
const { bus, events, finishedPromise } = makeCollectingEventBus();
|
||||
try {
|
||||
await executor.execute(requestContext, bus as any);
|
||||
await finishedPromise;
|
||||
} catch (err) {
|
||||
logger.warn(`[chat-connector] executor error binding=${binding.id}: ${err}`);
|
||||
await reply('⚠️ 内部エラーによりタスクを実行できませんでした。');
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = extractOutcome(events);
|
||||
try {
|
||||
await repo.addAuditLog(null, 'chat.mention.handled', 'chat', {
|
||||
bindingId: binding.id,
|
||||
platform: event.platform,
|
||||
spaceId: binding.spaceId,
|
||||
grantId: principal.grantId,
|
||||
state: outcome.state,
|
||||
localTaskId: outcome.localTaskId ?? null,
|
||||
});
|
||||
} catch { /* best-effort audit */ }
|
||||
|
||||
const replyText = await buildReply(outcome, repo, binding.spaceId, publicBaseUrl);
|
||||
await reply(replyText);
|
||||
}
|
||||
34
src/bridge/chat/chat-connector-types.ts
Normal file
34
src/bridge/chat/chat-connector-types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* chat-connector-types: shared types for the external chat connector
|
||||
* (issue #801, PR1 — Slack MVP). Platform-agnostic so PR2 (Discord) / PR3
|
||||
* (Teams) can reuse chat-connector-service.ts without changes.
|
||||
*/
|
||||
|
||||
/** Bot credentials, decrypted from chat_connector_bindings.bot_credentials_enc.
|
||||
* Never logged, never included in an API response. */
|
||||
export interface SlackBotCredentials {
|
||||
signingSecret: string;
|
||||
botToken: string;
|
||||
}
|
||||
|
||||
/** Normalized inbound mention event, platform-agnostic. */
|
||||
export interface ChatMentionEvent {
|
||||
platform: 'slack';
|
||||
externalWorkspaceId: string;
|
||||
externalChannelId: string;
|
||||
/** Mention text with the bot's own `<@BOTID>` token stripped. */
|
||||
text: string;
|
||||
/** Thread to reply into, if the mention occurred inside a thread. */
|
||||
threadTs?: string;
|
||||
/** Platform-native message timestamp, used to reply in-channel when not threaded. */
|
||||
messageTs?: string;
|
||||
/** External user id who sent the mention (best-effort, for audit logging only). */
|
||||
externalUserId?: string;
|
||||
}
|
||||
|
||||
/** Result of running a mention through the A2A executor bridge. */
|
||||
export interface ChatRunResult {
|
||||
ok: boolean;
|
||||
/** Human-readable summary to post back to the chat, already truncated. */
|
||||
replyText: string;
|
||||
}
|
||||
220
src/bridge/chat/chat-subsystem.test.ts
Normal file
220
src/bridge/chat/chat-subsystem.test.ts
Normal file
@ -0,0 +1,220 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* chat-subsystem HTTP-layer tests: config gating, url_verification handshake,
|
||||
* and the fail-closed dispatch gate (no binding / bad signature both ack 200
|
||||
* without calling handleSlackMention — see slack-adapter.test.ts and
|
||||
* chat-connector-service.test.ts for the unit-level signature/service tests
|
||||
* this route delegates to).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { randomBytes, randomUUID, createHmac } from 'crypto';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from '../../db/repository.js';
|
||||
import { encrypt } from '../../mcp/crypto.js';
|
||||
|
||||
let mockChatConfig: any = { enabled: false };
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../config.js')>('../../config.js');
|
||||
return { ...actual, loadConfig: vi.fn(() => ({ chat: mockChatConfig })) };
|
||||
});
|
||||
|
||||
const handleSlackMentionMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock('./chat-connector-service.js', () => ({
|
||||
handleSlackMention: (...args: unknown[]) => handleSlackMentionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../auth.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../auth.js')>('../auth.js');
|
||||
return { ...actual, requireAdmin: (_req: any, _res: any, next: any) => next() };
|
||||
});
|
||||
|
||||
import { setupChatSubsystem } from './chat-subsystem.js';
|
||||
|
||||
function sign(secret: string, timestamp: string, body: string): string {
|
||||
return 'v0=' + createHmac('sha256', secret).update(`v0:${timestamp}:${body}`, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
describe('chat-subsystem: Slack Events webhook', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let ownerId = '';
|
||||
let spaceId = '';
|
||||
let clientId = '';
|
||||
let delegationId = '';
|
||||
let grantId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
handleSlackMentionMock.mockClear();
|
||||
mockChatConfig = { enabled: true, slack: {} };
|
||||
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-subsys-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
ownerId = owner.id;
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId, visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
clientId = `a2a_${randomUUID()}`;
|
||||
repo.createA2aClient({
|
||||
clientId, name: 'Test App', redirectUris: ['https://example.com/cb'],
|
||||
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||
keyFingerprint: null, status: 'active', createdBy: null,
|
||||
});
|
||||
delegationId = randomUUID();
|
||||
grantId = randomUUID();
|
||||
repo.createA2aDelegation({
|
||||
id: delegationId, userId: ownerId, clientId, grantId,
|
||||
grantedSpaceIds: [spaceId], grantedSkills: ['chat'],
|
||||
audience: null, expiresAt: null, revokedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seedBinding(signingSecret = 'correct-secret') {
|
||||
const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex');
|
||||
return repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: encrypt(JSON.stringify({ signingSecret, botToken: 'xoxb-test' }), key),
|
||||
});
|
||||
}
|
||||
|
||||
function appWith(a2aLimiter: any = {}): express.Express {
|
||||
const app = express();
|
||||
setupChatSubsystem(app, { repo, authActive: true, a2aLimiter });
|
||||
return app;
|
||||
}
|
||||
|
||||
it('does not mount the webhook route when chat.enabled is false', async () => {
|
||||
mockChatConfig = { enabled: false };
|
||||
const app = appWith();
|
||||
const res = await request(app).post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'url_verification', challenge: 'abc' }));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('does not mount the webhook when A2A governance is unavailable', async () => {
|
||||
const app = appWith(null);
|
||||
const res = await request(app).post('/api/chat/slack/events').send('{}');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('echoes the url_verification challenge with no binding lookup and no dispatch', async () => {
|
||||
const app = appWith();
|
||||
const res = await request(app)
|
||||
.post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'url_verification', challenge: 'chal-123' }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.challenge).toBe('chal-123');
|
||||
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acks 200 without dispatching when no binding matches the (team, channel) — fail-closed, no enumeration signal', async () => {
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', team_id: 'T-unknown',
|
||||
event: { type: 'app_mention', channel: 'C-unknown', text: 'hi', ts: '1.1' },
|
||||
});
|
||||
const res = await request(app).post('/api/chat/slack/events').set('Content-Type', 'application/json').send(body);
|
||||
expect(res.status).toBe(200);
|
||||
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acks 200 without dispatching on an invalid signature — same status as the no-binding case', async () => {
|
||||
seedBinding();
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', event_id: 'Ev-valid-1', team_id: 'T1',
|
||||
event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' },
|
||||
});
|
||||
const res = await request(app).post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Slack-Signature', 'v0=' + '0'.repeat(64))
|
||||
.set('X-Slack-Request-Timestamp', String(Math.floor(Date.now() / 1000)))
|
||||
.send(body);
|
||||
expect(res.status).toBe(200);
|
||||
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('acks 200 without dispatching on a replayed (stale) timestamp', async () => {
|
||||
seedBinding();
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', event_id: 'Ev-valid-dispatch-1', team_id: 'T1',
|
||||
event: { type: 'app_mention', channel: 'C1', text: 'hi', ts: '1.1' },
|
||||
});
|
||||
const staleTimestamp = String(Math.floor(Date.now() / 1000) - 3600); // 1h old
|
||||
const res = await request(app).post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Slack-Signature', sign('correct-secret', staleTimestamp, body))
|
||||
.set('X-Slack-Request-Timestamp', staleTimestamp)
|
||||
.send(body);
|
||||
expect(res.status).toBe(200);
|
||||
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches handleSlackMention exactly once on a validly signed app_mention', async () => {
|
||||
seedBinding();
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', event_id: 'Ev-valid-mention-1', team_id: 'T1',
|
||||
event: { type: 'app_mention', channel: 'C1', text: '<@U1> hi there', ts: '1700000000.0001' },
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const res = await request(app).post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Slack-Signature', sign('correct-secret', timestamp, body))
|
||||
.set('X-Slack-Request-Timestamp', timestamp)
|
||||
.send(body);
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(handleSlackMentionMock).toHaveBeenCalledTimes(1);
|
||||
const [event] = handleSlackMentionMock.mock.calls[0];
|
||||
expect(event.externalWorkspaceId).toBe('T1');
|
||||
expect(event.externalChannelId).toBe('C1');
|
||||
expect(event.text).toBe('hi there');
|
||||
});
|
||||
|
||||
it('acknowledges a Slack retry without dispatching the same event twice', async () => {
|
||||
seedBinding();
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', event_id: 'Ev-retry-1', team_id: 'T1',
|
||||
event: { type: 'app_mention', channel: 'C1', text: '<@U1> once', ts: '1700000000.0001' },
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Slack-Signature': sign('correct-secret', timestamp, body), 'X-Slack-Request-Timestamp': timestamp };
|
||||
expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect((await request(app).post('/api/chat/slack/events').set(headers).send(body)).status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(handleSlackMentionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not dispatch for a non-app_mention event_callback (e.g. plain message)', async () => {
|
||||
seedBinding();
|
||||
const app = appWith();
|
||||
const body = JSON.stringify({
|
||||
type: 'event_callback', team_id: 'T1',
|
||||
event: { type: 'message', channel: 'C1', text: 'not a mention', ts: '1.1' },
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const res = await request(app).post('/api/chat/slack/events')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Slack-Signature', sign('correct-secret', timestamp, body))
|
||||
.set('X-Slack-Request-Timestamp', timestamp)
|
||||
.send(body);
|
||||
expect(res.status).toBe(200);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(handleSlackMentionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
139
src/bridge/chat/chat-subsystem.ts
Normal file
139
src/bridge/chat/chat-subsystem.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* chat-subsystem: mounts the external chat connector (issue #801, PR1 —
|
||||
* Slack MVP). No-op unless `chat.enabled: true` in config.yaml. Follows the
|
||||
* same "verbatim extraction, gated by config" shape as a2a-subsystem.ts.
|
||||
*/
|
||||
import express from 'express';
|
||||
import type { Repository } from '../../db/repository.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import { loadConfig } from '../../config.js';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { createChatConnectorBindingsAdminRouter } from './chat-connector-bindings-api.js';
|
||||
import {
|
||||
verifySlackSignature,
|
||||
isUrlVerification,
|
||||
parseAppMentionEvent,
|
||||
DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||
} from './slack-adapter.js';
|
||||
import { handleSlackMention } from './chat-connector-service.js';
|
||||
import type { A2aLimiter } from '../a2a/limiter.js';
|
||||
import { decrypt, loadKeyFromEnv } from '../../mcp/crypto.js';
|
||||
|
||||
export interface ChatSubsystemDeps {
|
||||
repo: Repository;
|
||||
authActive: boolean;
|
||||
/** Shared A2A governance limiter (see a2a-subsystem.ts). Undefined when a2a
|
||||
* is not enabled — in that case the chat path still runs but with no
|
||||
* rate/concurrency/skill-budget governance beyond what the executor itself
|
||||
* enforces without a limiter (none), so operators should enable a2a too. */
|
||||
a2aLimiter?: A2aLimiter;
|
||||
}
|
||||
|
||||
export function setupChatSubsystem(app: express.Express, deps: ChatSubsystemDeps): void {
|
||||
const { repo, authActive, a2aLimiter } = deps;
|
||||
const chatCfg = (loadConfig() as any).chat ?? {};
|
||||
if (!chatCfg.enabled) return;
|
||||
|
||||
if (!authActive) {
|
||||
logger.warn('[chat-connector] chat.enabled but auth is not active; admin binding API will reject all requests');
|
||||
}
|
||||
// --- Admin CRUD for bindings (no UI wizard in PR1 — manual seed only) ---
|
||||
app.use('/api/admin/chat/bindings', express.json(), requireAdmin, createChatConnectorBindingsAdminRouter(repo, authActive));
|
||||
|
||||
// The mention path must share A2A's limiter. Do not mount it when A2A did
|
||||
// not initialize: otherwise an external webhook bypasses rate/concurrency
|
||||
// and skill-budget governance despite the admin configuration saying it is
|
||||
// disabled.
|
||||
if (!a2aLimiter) {
|
||||
logger.warn('[chat-connector] chat.enabled but a2a.enabled is false (or a2a failed to mount); Slack webhook is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAgeSec: number = chatCfg.slack?.signingSecretMaxAgeSec ?? DEFAULT_SLACK_SIGNING_MAX_AGE_SEC;
|
||||
const publicBaseUrl: string | undefined = chatCfg.publicBaseUrl;
|
||||
|
||||
// --- Slack Events API webhook ---
|
||||
// express.raw() (not json()) — signature verification requires the exact
|
||||
// raw bytes Slack signed. Must ack within Slack's ~3s budget: verification
|
||||
// is synchronous/local (DB lookup + HMAC, no outbound network), so we
|
||||
// finish it before responding, then run the actual task fire-and-forget.
|
||||
app.post('/api/chat/slack/events', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
|
||||
const rawBody = req.body as Buffer;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody.toString('utf8'));
|
||||
} catch {
|
||||
res.status(400).json({ error: 'invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
// url_verification handshake: Slack sends this once, when the Events API
|
||||
// Request URL is first configured, before any binding necessarily exists
|
||||
// for that team. There is no channel to key a per-binding signing secret
|
||||
// lookup on, so PR1 does not verify this specific call — it has no side
|
||||
// effects (it only echoes back the challenge value Slack itself sent).
|
||||
if (isUrlVerification(parsed)) {
|
||||
res.status(200).json({ challenge: parsed.challenge });
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = (parsed as any)?.team_id;
|
||||
const channelId = (parsed as any)?.event?.channel;
|
||||
if (typeof teamId !== 'string' || typeof channelId !== 'string') {
|
||||
res.status(200).json({ ok: true }); // nothing routable — ack and drop
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = repo.findActiveChatConnectorBinding('slack', teamId, channelId);
|
||||
if (!binding) {
|
||||
// fail-closed, no enumeration signal: identical 200 ack whether or not
|
||||
// a binding exists for this team/channel.
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
let signingSecret: string;
|
||||
try {
|
||||
const decrypted = decrypt(binding.botCredentialsEnc, loadKeyFromEnv());
|
||||
signingSecret = (JSON.parse(decrypted) as { signingSecret: string }).signingSecret;
|
||||
} catch (err) {
|
||||
logger.warn(`[chat-connector] failed to decrypt signing secret binding=${binding.id}: ${err}`);
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = verifySlackSignature(
|
||||
{ signature: req.header('X-Slack-Signature'), timestamp: req.header('X-Slack-Request-Timestamp') },
|
||||
rawBody,
|
||||
signingSecret,
|
||||
Math.floor(Date.now() / 1000),
|
||||
maxAgeSec,
|
||||
);
|
||||
if (!verified) {
|
||||
// Same 200 ack as "no binding" — do not leak signature-validity via status code.
|
||||
logger.warn(`[chat-connector] signature verification failed binding=${binding.id}`);
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = (parsed as any)?.event_id;
|
||||
if (typeof eventId !== 'string' || !repo.claimChatConnectorEvent(eventId)) {
|
||||
// Slack retries use the same envelope event_id. Acknowledge the retry
|
||||
// without creating a second task or posting a duplicate thread reply.
|
||||
res.status(200).json({ ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verified — ack immediately, then run the task in the background.
|
||||
res.status(200).json({ ok: true });
|
||||
|
||||
const event = parseAppMentionEvent(parsed);
|
||||
if (!event) return; // not an app_mention (or bot/edited-message echo) — nothing to do
|
||||
|
||||
handleSlackMention(event, { repo, limiter: a2aLimiter, publicBaseUrl }).catch((err) => {
|
||||
logger.warn(`[chat-connector] handleSlackMention failed: ${err}`);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('[chat-connector] chat connector subsystem enabled (slack)');
|
||||
}
|
||||
193
src/bridge/chat/slack-adapter.test.ts
Normal file
193
src/bridge/chat/slack-adapter.test.ts
Normal file
@ -0,0 +1,193 @@
|
||||
// @vitest-environment node
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
verifySlackSignature,
|
||||
isUrlVerification,
|
||||
parseAppMentionEvent,
|
||||
SlackChatClient,
|
||||
DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||
} from './slack-adapter.js';
|
||||
|
||||
const SECRET = 'test-signing-secret';
|
||||
|
||||
function sign(timestamp: string, body: string, secret: string = SECRET): string {
|
||||
const base = `v0:${timestamp}:${body}`;
|
||||
return 'v0=' + createHmac('sha256', secret).update(base, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
describe('verifySlackSignature', () => {
|
||||
it('accepts a correctly signed, fresh request', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const body = JSON.stringify({ hello: 'world' });
|
||||
const timestamp = String(nowSec - 5);
|
||||
const signature = sign(timestamp, body);
|
||||
expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered body (signature mismatch)', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const timestamp = String(nowSec - 5);
|
||||
const signature = sign(timestamp, JSON.stringify({ hello: 'world' }));
|
||||
const tamperedBody = JSON.stringify({ hello: 'mallory' });
|
||||
expect(verifySlackSignature({ signature, timestamp }, tamperedBody, SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a signature produced with the wrong secret', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const body = JSON.stringify({ hello: 'world' });
|
||||
const timestamp = String(nowSec - 5);
|
||||
const signature = sign(timestamp, body, 'wrong-secret');
|
||||
expect(verifySlackSignature({ signature, timestamp }, body, SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a replayed (stale) timestamp beyond maxAgeSec', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const body = JSON.stringify({ hello: 'world' });
|
||||
const staleTimestamp = String(nowSec - (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60));
|
||||
const signature = sign(staleTimestamp, body);
|
||||
expect(verifySlackSignature({ signature, timestamp: staleTimestamp }, body, SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a timestamp far in the future (clock-skew abuse)', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const body = JSON.stringify({ hello: 'world' });
|
||||
const futureTimestamp = String(nowSec + (DEFAULT_SLACK_SIGNING_MAX_AGE_SEC + 60));
|
||||
const signature = sign(futureTimestamp, body);
|
||||
expect(verifySlackSignature({ signature, timestamp: futureTimestamp }, body, SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('fail-closed: rejects when the signature header is missing', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
expect(verifySlackSignature({ signature: undefined, timestamp: String(nowSec) }, '{}', SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('fail-closed: rejects when the timestamp header is missing', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: undefined }, '{}', SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('fail-closed: rejects a non-numeric timestamp', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
expect(verifySlackSignature({ signature: 'v0=deadbeef', timestamp: 'not-a-number' }, '{}', SECRET, nowSec)).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a Buffer body identically to the equivalent string', () => {
|
||||
const nowSec = 1_700_000_000;
|
||||
const body = JSON.stringify({ hello: 'world' });
|
||||
const timestamp = String(nowSec - 5);
|
||||
const signature = sign(timestamp, body);
|
||||
expect(verifySlackSignature({ signature, timestamp }, Buffer.from(body, 'utf8'), SECRET, nowSec)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUrlVerification', () => {
|
||||
it('recognizes a well-formed url_verification payload', () => {
|
||||
expect(isUrlVerification({ type: 'url_verification', challenge: 'abc123' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects payloads missing the challenge field', () => {
|
||||
expect(isUrlVerification({ type: 'url_verification' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects event_callback payloads', () => {
|
||||
expect(isUrlVerification({ type: 'event_callback', event: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null/non-object input', () => {
|
||||
expect(isUrlVerification(null)).toBe(false);
|
||||
expect(isUrlVerification('challenge')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAppMentionEvent', () => {
|
||||
const envelope = (event: Record<string, unknown>, teamId = 'T123') => ({
|
||||
type: 'event_callback',
|
||||
team_id: teamId,
|
||||
event: { type: 'app_mention', channel: 'C456', text: '<@U000BOT> summarize the thread', ts: '1700000000.000100', ...event },
|
||||
});
|
||||
|
||||
it('parses a genuine app_mention and strips the bot mention token', () => {
|
||||
const result = parseAppMentionEvent(envelope({}), 'U000BOT');
|
||||
expect(result).toEqual({
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
externalChannelId: 'C456',
|
||||
text: 'summarize the thread',
|
||||
threadTs: undefined,
|
||||
messageTs: '1700000000.000100',
|
||||
externalUserId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries thread_ts and user when present', () => {
|
||||
const result = parseAppMentionEvent(envelope({ thread_ts: '1700000000.000050', user: 'U999' }), 'U000BOT');
|
||||
expect(result?.threadTs).toBe('1700000000.000050');
|
||||
expect(result?.externalUserId).toBe('U999');
|
||||
});
|
||||
|
||||
it('best-effort strips a leading mention token when botUserId is unknown', () => {
|
||||
const result = parseAppMentionEvent(envelope({}));
|
||||
expect(result?.text).toBe('summarize the thread');
|
||||
});
|
||||
|
||||
it('ignores non-app_mention event types', () => {
|
||||
expect(parseAppMentionEvent(envelope({ type: 'message' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores bot-authored messages (bot_id present)', () => {
|
||||
expect(parseAppMentionEvent(envelope({ bot_id: 'B123' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores edited/deleted echoes (subtype present)', () => {
|
||||
expect(parseAppMentionEvent(envelope({ subtype: 'message_changed' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores non-event_callback envelopes', () => {
|
||||
expect(parseAppMentionEvent({ type: 'url_verification', challenge: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores envelopes missing team_id/channel/text', () => {
|
||||
expect(parseAppMentionEvent({ type: 'event_callback', event: { type: 'app_mention' } })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-object input', () => {
|
||||
expect(parseAppMentionEvent(null)).toBeNull();
|
||||
expect(parseAppMentionEvent('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlackChatClient.postMessage', () => {
|
||||
it('posts to chat.postMessage with the bot token and returns true on ok', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||
const result = await client.postMessage({ channel: 'C456', text: 'hello', threadTs: '1700000000.000100' });
|
||||
expect(result).toBe(true);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://slack.com/api/chat.postMessage');
|
||||
expect(init.headers.Authorization).toBe('Bearer test-slack-bot-token');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body).toEqual({ channel: 'C456', text: 'hello', thread_ts: '1700000000.000100' });
|
||||
});
|
||||
|
||||
it('returns false and does not throw on a Slack API error response', async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: false, error: 'channel_not_found' }),
|
||||
});
|
||||
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||
const result = await client.postMessage({ channel: 'C456', text: 'hello' });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false and does not throw on a network error', async () => {
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNRESET'));
|
||||
const client = new SlackChatClient({ signingSecret: SECRET, botToken: 'test-slack-bot-token' }, fetchImpl as unknown as typeof fetch);
|
||||
const result = await client.postMessage({ channel: 'C456', text: 'hello' });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
155
src/bridge/chat/slack-adapter.ts
Normal file
155
src/bridge/chat/slack-adapter.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* slack-adapter: Slack Events API signature verification, app_mention
|
||||
* parsing, and the `chat.postMessage` reply client (issue #801, PR1).
|
||||
*
|
||||
* Signature verification follows Slack's documented scheme:
|
||||
* https://api.slack.com/authentication/verifying-requests-from-slack
|
||||
* base = "v0:" + timestamp + ":" + rawBody
|
||||
* expected = "v0=" + HMAC_SHA256(signingSecret, base)
|
||||
* fail-closed: any missing header, malformed signature, or a timestamp older
|
||||
* than `maxAgeSec` (replay protection) is rejected.
|
||||
*/
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import type { SlackBotCredentials, ChatMentionEvent } from './chat-connector-types.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
export const DEFAULT_SLACK_SIGNING_MAX_AGE_SEC = 300; // 5 minutes, matches Slack's own recommendation
|
||||
|
||||
export interface SlackSignatureHeaders {
|
||||
/** X-Slack-Signature header value, e.g. "v0=abcdef...". */
|
||||
signature: string | undefined;
|
||||
/** X-Slack-Request-Timestamp header value (unix seconds as string). */
|
||||
timestamp: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Slack Events API request signature against the raw request body.
|
||||
* fail-closed: returns false on any missing/malformed input, signature
|
||||
* mismatch, or stale timestamp (replay protection).
|
||||
*/
|
||||
export function verifySlackSignature(
|
||||
headers: SlackSignatureHeaders,
|
||||
rawBody: Buffer | string,
|
||||
signingSecret: string,
|
||||
nowSec: number = Math.floor(Date.now() / 1000),
|
||||
maxAgeSec: number = DEFAULT_SLACK_SIGNING_MAX_AGE_SEC,
|
||||
): boolean {
|
||||
const { signature, timestamp } = headers;
|
||||
if (!signature || !timestamp) return false;
|
||||
if (!/^\d+$/.test(timestamp)) return false;
|
||||
|
||||
const tsNum = Number(timestamp);
|
||||
if (!Number.isFinite(tsNum)) return false;
|
||||
// Replay protection: reject requests whose timestamp is too old OR
|
||||
// suspiciously in the future (clock skew abuse). fail-closed both ways.
|
||||
if (Math.abs(nowSec - tsNum) > maxAgeSec) return false;
|
||||
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
|
||||
const base = `v0:${timestamp}:${body}`;
|
||||
const expected = 'v0=' + createHmac('sha256', signingSecret).update(base, 'utf8').digest('hex');
|
||||
|
||||
const expectedBuf = Buffer.from(expected, 'utf8');
|
||||
const actualBuf = Buffer.from(signature, 'utf8');
|
||||
if (expectedBuf.length !== actualBuf.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(expectedBuf, actualBuf);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Slack Events API `url_verification` handshake payload. */
|
||||
export interface SlackUrlVerificationPayload {
|
||||
type: 'url_verification';
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
export function isUrlVerification(body: unknown): body is SlackUrlVerificationPayload {
|
||||
return !!body && typeof body === 'object' && (body as any).type === 'url_verification'
|
||||
&& typeof (body as any).challenge === 'string';
|
||||
}
|
||||
|
||||
interface SlackEventEnvelope {
|
||||
type?: string;
|
||||
team_id?: string;
|
||||
event?: {
|
||||
type?: string;
|
||||
channel?: string;
|
||||
text?: string;
|
||||
thread_ts?: string;
|
||||
ts?: string;
|
||||
user?: string;
|
||||
bot_id?: string;
|
||||
subtype?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Slack Events API `event_callback` envelope into a normalized
|
||||
* ChatMentionEvent. Returns null when the event is not an app_mention, is
|
||||
* missing required fields, or originates from a bot (including our own bot
|
||||
* — Slack echoes app_mention only for genuine @mentions, but subtype/bot_id
|
||||
* guards against edited/bot-authored messages that could otherwise loop).
|
||||
*/
|
||||
export function parseAppMentionEvent(body: unknown, botUserId?: string): ChatMentionEvent | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const envelope = body as SlackEventEnvelope;
|
||||
if (envelope.type !== 'event_callback') return null;
|
||||
const event = envelope.event;
|
||||
if (!event || event.type !== 'app_mention') return null;
|
||||
if (event.bot_id) return null; // never act on bot-authored messages
|
||||
if (event.subtype) return null; // ignore message_changed/deleted echoes etc.
|
||||
if (!envelope.team_id || !event.channel || typeof event.text !== 'string') return null;
|
||||
|
||||
// Strip the bot's own mention token (e.g. "<@U012ABC> do the thing" → "do the thing").
|
||||
let text = event.text;
|
||||
if (botUserId) {
|
||||
text = text.replace(new RegExp(`<@${botUserId}>`, 'g'), '').trim();
|
||||
} else {
|
||||
// Best-effort: strip the first leading mention token even if we don't know our own id.
|
||||
text = text.replace(/^\s*<@[A-Z0-9]+>\s*/, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: envelope.team_id,
|
||||
externalChannelId: event.channel,
|
||||
text,
|
||||
threadTs: event.thread_ts,
|
||||
messageTs: event.ts,
|
||||
externalUserId: event.user,
|
||||
};
|
||||
}
|
||||
|
||||
const SLACK_API_BASE = 'https://slack.com/api';
|
||||
|
||||
/** Thin `chat.postMessage` client. Never logs the bot token. */
|
||||
export class SlackChatClient {
|
||||
constructor(private readonly credentials: SlackBotCredentials, private readonly fetchImpl: typeof fetch = fetch) {}
|
||||
|
||||
async postMessage(opts: { channel: string; text: string; threadTs?: string }): Promise<boolean> {
|
||||
try {
|
||||
const res = await this.fetchImpl(`${SLACK_API_BASE}/chat.postMessage`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Authorization: `Bearer ${this.credentials.botToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: opts.channel,
|
||||
text: opts.text,
|
||||
...(opts.threadTs ? { thread_ts: opts.threadTs } : {}),
|
||||
}),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
|
||||
if (!res.ok || !json?.ok) {
|
||||
logger.warn(`[slack-adapter] chat.postMessage failed status=${res.status} error=${json?.error ?? 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`[slack-adapter] chat.postMessage error: ${err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,7 @@ import {
|
||||
decideAccess,
|
||||
handleConsoleSocket,
|
||||
createConsoleStatusRouter,
|
||||
createConsoleStatusStubRouter,
|
||||
createConsoleSessionRouter,
|
||||
createConsoleSessionsRouter,
|
||||
createConsoleSessionCloseRouter,
|
||||
@ -478,6 +479,44 @@ describe('createConsoleStatusRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConsoleStatusStubRouter', () => {
|
||||
// Issue #785: when SSH is disabled, the real createConsoleStatusRouter
|
||||
// never mounts (no registry/resolveTask/resolveSshAccess to wire it
|
||||
// with), but the UI still polls this path every 5s. The stub always
|
||||
// answers `{ active: false }` without touching any task/registry.
|
||||
function buildApp(opts: { authActive?: boolean; user?: any } = {}) {
|
||||
const app = express();
|
||||
if (opts.user) {
|
||||
app.use((req, _res, next) => { (req as any).user = opts.user; next(); });
|
||||
}
|
||||
app.use('/api', createConsoleStatusStubRouter({
|
||||
requireAuth: (_req: any, _res: any, next: any) => next(),
|
||||
authActive: opts.authActive,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
it('always returns 200 { active: false } regardless of task id', async () => {
|
||||
const app = buildApp({ user: { id: 'alice', role: 'user' } });
|
||||
const res = await request(app).get('/api/local/tasks/anything-at-all/console/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it('authActive=true and unauthenticated → 401', async () => {
|
||||
const app = buildApp({ authActive: true }); // no user middleware
|
||||
const res = await request(app).get('/api/local/tasks/t1/console/status');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
|
||||
const app = buildApp({ authActive: false });
|
||||
const res = await request(app).get('/api/local/tasks/t1/console/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ active: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConsoleSessionRouter', () => {
|
||||
it('no-auth (authActive=false): synthesizes a local admin user instead of 401', async () => {
|
||||
let seenUser: any;
|
||||
|
||||
@ -431,6 +431,41 @@ export function createConsoleStatusRouter(deps: {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST router exposing GET /local/tasks/:taskId/console/status as a minimal
|
||||
* stub for environments where SSH is disabled (`ssh.enabled=false`) or the
|
||||
* key isn't configured, so `createConsoleStatusRouter` (and the session
|
||||
* registry / resolveTask / resolveSshAccess it needs) never gets mounted.
|
||||
*
|
||||
* Without SOME router on this path, the UI's App.tsx 5-second console/status
|
||||
* poll (see the "issue #347" note on `createConsoleStatusRouter` above) 404s
|
||||
* forever on every task, spamming the DevTools console with unsuppressible
|
||||
* network errors even though the feature is intentionally off and there is
|
||||
* no functional harm (issue #785).
|
||||
*
|
||||
* Unlike the real router, this never touches a task/registry — SSH consoles
|
||||
* cannot exist when this stub is mounted, so the answer is unconditionally
|
||||
* `{ active: false }`. The auth gate is still applied (mirrors the real
|
||||
* router's `requireAuth` + no-auth synthetic-user behavior) purely for
|
||||
* consistency; there's no data to protect here.
|
||||
*/
|
||||
export function createConsoleStatusStubRouter(deps: { requireAuth: any; authActive?: boolean }): Router {
|
||||
const r = Router();
|
||||
r.get(
|
||||
'/local/tasks/:taskId/console/status',
|
||||
deps.requireAuth,
|
||||
(req: Request, res: Response) => {
|
||||
const user = resolveConsoleUser(req, deps.authActive ?? true);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'unauthenticated' });
|
||||
return;
|
||||
}
|
||||
res.json({ active: false });
|
||||
},
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Idle threshold: a session with no activity for > this is reported `idle`. */
|
||||
const CONSOLE_IDLE_THRESHOLD_MS = 60_000;
|
||||
/** A session is `agent_active` if the agent wrote input within this window. */
|
||||
|
||||
@ -257,6 +257,26 @@ describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () =>
|
||||
expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true);
|
||||
});
|
||||
|
||||
it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => {
|
||||
// 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は
|
||||
// delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の
|
||||
// タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。
|
||||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||||
tmpDirs.push(workspacePath);
|
||||
const REAL_TOOL = {
|
||||
v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r',
|
||||
kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1',
|
||||
movement: 'delegate:d', payload: { tool: 'Read' },
|
||||
};
|
||||
writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]);
|
||||
|
||||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// ツールイベントが(correlation 上書きされていても)返る
|
||||
expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true);
|
||||
});
|
||||
|
||||
it('timeline: events.jsonl 不在なら空配列', async () => {
|
||||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||||
tmpDirs.push(workspacePath);
|
||||
|
||||
@ -81,6 +81,10 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
const taskId = Number(req.params.id);
|
||||
const runId = req.params.delegateRunId;
|
||||
const ownerJobId = typeof req.query.jobId === 'string' ? req.query.jobId : null;
|
||||
// reconstructDelegateRuns と同じ帰属判定。tool_call/tool_result は
|
||||
// ペアリング用に correlationId を per-tool UUID で上書きするため、実行への
|
||||
// 帰属は delegateRunId タグで見る(旧ログは correlationId フォールバック)。
|
||||
const belongsToRun = (e: EventBase): boolean => (e.delegateRunId ?? e.correlationId) === runId;
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||||
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
|
||||
@ -96,16 +100,16 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
}
|
||||
events = readJobEvents(job.worktreePath);
|
||||
} else {
|
||||
// jobId 未指定: 親 events を読み、correlationId 一致がなければサブジョブを線形探索
|
||||
// jobId 未指定: 親 events を読み、run 帰属イベントが無ければサブジョブを線形探索
|
||||
events = readEvents(task!);
|
||||
if (!events.some((e) => e.correlationId === runId)) {
|
||||
if (!events.some(belongsToRun)) {
|
||||
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||||
if (latestJob) {
|
||||
for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) {
|
||||
if (!job.worktreePath) continue;
|
||||
if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue;
|
||||
const ev = readJobEvents(job.worktreePath);
|
||||
if (ev.some((e) => e.correlationId === runId)) {
|
||||
if (ev.some(belongsToRun)) {
|
||||
events = ev;
|
||||
break;
|
||||
}
|
||||
@ -114,7 +118,7 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ events: events.filter((e) => e.correlationId === runId) });
|
||||
res.json({ events: events.filter(belongsToRun) });
|
||||
} catch (err) {
|
||||
logger.error(`[delegate-runs] timeline error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to fetch delegate run timeline' });
|
||||
|
||||
205
src/bridge/llm-workers-api.test.ts
Normal file
205
src/bridge/llm-workers-api.test.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* GET /api/llm/workers + validateLlmSelection tests.
|
||||
*
|
||||
* Coverage:
|
||||
* - only workers with an execution role (auto|fast|quality) are returned
|
||||
* - config order preserved
|
||||
* - response never leaks endpoint / apiKey / extraBody
|
||||
* - response shape is exactly {id, model, roles, reasoningEfforts, vlm, enabled}
|
||||
* - validateLlmSelection: one case per rule
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { ConfigManager } from '../config-manager.js';
|
||||
import type { WorkerDef } from '../config.js';
|
||||
import { createLlmWorkersRouter, validateLlmSelection } from './llm-workers-api.js';
|
||||
|
||||
describe('GET /api/llm/workers', () => {
|
||||
let app: express.Application;
|
||||
let cm: ConfigManager;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-'));
|
||||
writeFileSync(join(tempDir, 'config.yaml'), [
|
||||
'config_version: 2',
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: w-auto',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11434/v1',
|
||||
' model: auto-model',
|
||||
' roles: [auto, fast]',
|
||||
' reasoning_efforts: [low, high]',
|
||||
' api_key: super-secret-key',
|
||||
' extra_body:',
|
||||
" foo: 'bar'",
|
||||
" reasoning_effort: 'max'",
|
||||
' enabled: true',
|
||||
' - id: w-title-only',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11435/v1',
|
||||
' model: title-model',
|
||||
' roles: [title]',
|
||||
' enabled: true',
|
||||
' - id: w-reflection-only',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11436/v1',
|
||||
' model: reflection-model',
|
||||
' roles: [reflection]',
|
||||
' enabled: true',
|
||||
' - id: w-quality-disabled',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11437/v1',
|
||||
' model: quality-model',
|
||||
' roles: [quality]',
|
||||
' vlm: true',
|
||||
' enabled: false',
|
||||
].join('\n'));
|
||||
cm = new ConfigManager(join(tempDir, 'config.yaml'));
|
||||
app = express();
|
||||
app.use('/api/llm/workers', createLlmWorkersRouter(cm));
|
||||
});
|
||||
|
||||
it('returns only execution-role workers, in config order', async () => {
|
||||
const res = await request(app).get('/api/llm/workers');
|
||||
expect(res.status).toBe(200);
|
||||
const ids = res.body.workers.map((w: { id: string }) => w.id);
|
||||
expect(ids).toEqual(['w-auto', 'w-quality-disabled']);
|
||||
});
|
||||
|
||||
it('response objects contain exactly the 6 documented fields', async () => {
|
||||
const res = await request(app).get('/api/llm/workers');
|
||||
const w = res.body.workers.find((x: { id: string }) => x.id === 'w-auto');
|
||||
expect(Object.keys(w).sort()).toEqual(
|
||||
['enabled', 'id', 'model', 'reasoningEfforts', 'roles', 'vlm'].sort(),
|
||||
);
|
||||
expect(w).toMatchObject({
|
||||
id: 'w-auto',
|
||||
model: 'auto-model',
|
||||
roles: ['auto', 'fast'],
|
||||
reasoningEfforts: ['low', 'high'],
|
||||
vlm: false,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects enabled:false and vlm:true correctly', async () => {
|
||||
const res = await request(app).get('/api/llm/workers');
|
||||
const w = res.body.workers.find((x: { id: string }) => x.id === 'w-quality-disabled');
|
||||
expect(w).toMatchObject({ enabled: false, vlm: true, roles: ['quality'] });
|
||||
});
|
||||
|
||||
it('NEVER leaks endpoint / apiKey / extraBody (secret regression guard)', async () => {
|
||||
const res = await request(app).get('/api/llm/workers');
|
||||
const raw = JSON.stringify(res.body);
|
||||
expect(raw).not.toContain('endpoint');
|
||||
expect(raw).not.toContain('apiKey');
|
||||
expect(raw).not.toContain('extraBody');
|
||||
// Sentinel values that WOULD serialize if the corresponding field leaked.
|
||||
// w-auto seeds a real api_key AND a real extra_body ({foo:'bar', ...}); a
|
||||
// non-empty extraBody is what makes the extraBody guard non-vacuous (an
|
||||
// undefined value would be dropped by JSON.stringify and pass trivially).
|
||||
expect(raw).not.toContain('super-secret-key'); // api_key sentinel
|
||||
expect(raw).not.toContain('foo'); // extra_body sentinel
|
||||
const wAuto = res.body.workers.find((x: { id: string }) => x.id === 'w-auto');
|
||||
expect(wAuto).not.toHaveProperty('extraBody');
|
||||
});
|
||||
|
||||
it('excludes title-only and reflection-only workers entirely', async () => {
|
||||
const res = await request(app).get('/api/llm/workers');
|
||||
const ids = res.body.workers.map((w: { id: string }) => w.id);
|
||||
expect(ids).not.toContain('w-title-only');
|
||||
expect(ids).not.toContain('w-reflection-only');
|
||||
});
|
||||
});
|
||||
|
||||
// requireAuth gating (401 when auth is active) is wired in auth-subsystem.ts as
|
||||
// `app.use('/api/llm/workers', requireAuth)`, mirroring the shared pattern used by
|
||||
// every other /api/* namespace (see auth-subsystem.ts). That middleware is exercised
|
||||
// by the auth-subsystem integration tests; here we confirm the no-auth path (the
|
||||
// router mounted standalone, as server.ts does when auth is inactive) returns 200.
|
||||
describe('GET /api/llm/workers — no-auth mode', () => {
|
||||
it('returns 200 without any auth middleware in front', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'llm-workers-api-noauth-'));
|
||||
writeFileSync(join(tempDir, 'config.yaml'), [
|
||||
'config_version: 2',
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: w1',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://localhost:11434/v1',
|
||||
' model: m',
|
||||
' roles: [auto]',
|
||||
' enabled: true',
|
||||
].join('\n'));
|
||||
const cm2 = new ConfigManager(join(tempDir, 'config.yaml'));
|
||||
const app2 = express();
|
||||
app2.use('/api/llm/workers', createLlmWorkersRouter(cm2));
|
||||
const res = await request(app2).get('/api/llm/workers');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.workers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLlmSelection', () => {
|
||||
const workers: WorkerDef[] = [
|
||||
{ id: 'w-auto', endpoint: 'x', model: 'm', roles: ['auto'], reasoningEfforts: ['low', 'high'], enabled: true },
|
||||
{ id: 'w-disabled', endpoint: 'x', model: 'm', roles: ['auto'], enabled: false },
|
||||
{ id: 'w-title-only', endpoint: 'x', model: 'm', roles: ['title'], enabled: true },
|
||||
{ id: 'w-no-efforts', endpoint: 'x', model: 'm', roles: ['fast'], enabled: true },
|
||||
];
|
||||
|
||||
it('both null/undefined → ok (clearing selection)', () => {
|
||||
expect(validateLlmSelection(workers, null, null)).toEqual({ ok: true });
|
||||
expect(validateLlmSelection(workers, undefined, undefined)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('effort provided without workerId → error', () => {
|
||||
const res = validateLlmSelection(workers, null, 'high');
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('ワーカー指定');
|
||||
});
|
||||
|
||||
it('workerId provided but not found in list → error', () => {
|
||||
const res = validateLlmSelection(workers, 'does-not-exist', null);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('存在しません');
|
||||
});
|
||||
|
||||
it('matching worker exists but disabled → error', () => {
|
||||
const res = validateLlmSelection(workers, 'w-disabled', null);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('無効化');
|
||||
});
|
||||
|
||||
it('matching worker exists but has no execution role → error', () => {
|
||||
const res = validateLlmSelection(workers, 'w-title-only', null);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('実行できません');
|
||||
});
|
||||
|
||||
it('effort not in worker reasoningEfforts → error', () => {
|
||||
const res = validateLlmSelection(workers, 'w-auto', 'max');
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('対応していません');
|
||||
});
|
||||
|
||||
it('effort provided but worker declares no reasoningEfforts at all → error', () => {
|
||||
const res = validateLlmSelection(workers, 'w-no-efforts', 'low');
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toContain('対応していません');
|
||||
});
|
||||
|
||||
it('workerId only (no effort), valid + enabled + execution role → ok', () => {
|
||||
expect(validateLlmSelection(workers, 'w-auto', null)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('workerId + valid effort → ok', () => {
|
||||
expect(validateLlmSelection(workers, 'w-auto', 'high')).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
108
src/bridge/llm-workers-api.ts
Normal file
108
src/bridge/llm-workers-api.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { ConfigManager } from '../config-manager.js';
|
||||
import type { WorkerDef } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Phase 1 (LLM 選択): GET /api/llm/workers — タスクにピン留め可能なワーカー一覧。
|
||||
*
|
||||
* config.provider.workers から、実行ロール(auto|fast|quality)を1つ以上持つ
|
||||
* ワーカーだけを config 順で返す。title/reflection 専用ワーカーはジョブを
|
||||
* 実行できないため、選択肢から除外する。
|
||||
*
|
||||
* レスポンスは endpoint / apiKey / extraBody を絶対に含めない(UI に渡す
|
||||
* 必要がない秘密値・内部接続先のため)。GET /api/workers(config-api.ts)は
|
||||
* 管理系ダッシュボード向けで endpoint を返すが、こちらは選択 UI 専用の
|
||||
* 別エンドポイント。
|
||||
*
|
||||
* requireAuth は auth-subsystem.ts の `app.use('/api/llm/workers', requireAuth)`
|
||||
* (認証有効時のみ登録)が担う。このルーター自身は認証状態を意識しない。
|
||||
*/
|
||||
|
||||
/** タスクにピン留めしてジョブを実行できるロール。title/reflection 専用は含まない。 */
|
||||
const PINNABLE_EXECUTION_ROLES = new Set(['auto', 'fast', 'quality']);
|
||||
|
||||
/** roles が未設定/空の場合は全実行ロールを持つ扱い(config.ts の normalizeWorkerDefs の既定と揃える)。 */
|
||||
export function hasPinnableExecutionRole(worker: WorkerDef): boolean {
|
||||
if (!Array.isArray(worker.roles) || worker.roles.length === 0) return true;
|
||||
return worker.roles.some((r) => PINNABLE_EXECUTION_ROLES.has(r));
|
||||
}
|
||||
|
||||
export interface LlmWorkerListItem {
|
||||
id: string;
|
||||
model: string;
|
||||
roles: string[];
|
||||
reasoningEfforts: string[];
|
||||
vlm: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function toListItem(w: WorkerDef): LlmWorkerListItem {
|
||||
return {
|
||||
id: w.id,
|
||||
model: w.model ?? '',
|
||||
roles: Array.isArray(w.roles) ? w.roles : [],
|
||||
reasoningEfforts: Array.isArray(w.reasoningEfforts) ? w.reasoningEfforts : [],
|
||||
vlm: w.vlm === true,
|
||||
enabled: w.enabled !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLlmWorkersRouter(configManager: ConfigManager): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const cfg = configManager.getConfig();
|
||||
const workers = (cfg.provider?.workers ?? [])
|
||||
.filter(hasPinnableExecutionRole)
|
||||
.map(toListItem);
|
||||
res.json({ workers });
|
||||
} catch (e) {
|
||||
logger.error(`[llm-workers-api] GET / failed: ${String(e)}`);
|
||||
res.status(500).json({ error: 'ワーカー一覧の取得に失敗しました' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* タスクへの LLM ワーカー / reasoning effort ピン留めが妥当かを検証する純関数。
|
||||
* config アクセスは呼び出し側の責務(workers は呼び出し側が渡す)— Task 11
|
||||
* の task API・Task 13/14 の UI どちらからも import できるようにするため。
|
||||
*/
|
||||
export function validateLlmSelection(
|
||||
workers: WorkerDef[],
|
||||
workerId: string | null | undefined,
|
||||
effort: string | null | undefined,
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
const hasWorkerId = workerId !== null && workerId !== undefined && workerId !== '';
|
||||
const hasEffort = effort !== null && effort !== undefined && effort !== '';
|
||||
|
||||
if (!hasWorkerId && !hasEffort) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (hasEffort && !hasWorkerId) {
|
||||
return { ok: false, error: 'reasoning effort はワーカー指定とセットの場合のみ指定できます' };
|
||||
}
|
||||
|
||||
const worker = workers.find((w) => w.id === workerId);
|
||||
if (!worker) {
|
||||
return { ok: false, error: '指定されたワーカーは存在しません' };
|
||||
}
|
||||
if (worker.enabled === false) {
|
||||
return { ok: false, error: '指定されたワーカーは無効化されています' };
|
||||
}
|
||||
if (!hasPinnableExecutionRole(worker)) {
|
||||
return { ok: false, error: 'このワーカーはジョブを実行できません' };
|
||||
}
|
||||
if (hasEffort) {
|
||||
const efforts = Array.isArray(worker.reasoningEfforts) ? worker.reasoningEfforts : [];
|
||||
if (!efforts.includes(effort as string)) {
|
||||
return { ok: false, error: 'このワーカーは指定の reasoning effort に対応していません' };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@ -1289,6 +1289,18 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
||||
expect(res.body.error).toBe('piece_required');
|
||||
});
|
||||
|
||||
it('continuation carries over the task-level LLM pin (llmWorkerId/llmEffort) to the new job', async () => {
|
||||
const { task } = await setupTaskWithTerminalJob();
|
||||
await repo.updateLocalTask(task.id, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any);
|
||||
const res = await request(app)
|
||||
.post(`/api/local/tasks/${task.id}/continue`)
|
||||
.send({ piece: 'ssh-ops', instruction: 'go with pin' });
|
||||
expect(res.status).toBe(201);
|
||||
const newJob = await repo.getJob(res.body.jobId);
|
||||
expect(newJob?.requiredWorkerId).toBe('worker-gpu-1');
|
||||
expect(newJob?.reasoningEffort).toBe('high');
|
||||
});
|
||||
|
||||
it('all DB-valid terminal states allow continuation', async () => {
|
||||
// jobs.status CHECK constraint permits these four terminal states.
|
||||
// 'aborted' is intentionally absent — the worker maps abort outcomes to
|
||||
|
||||
@ -8,6 +8,7 @@ import { registerLocalTaskToolRequestRoutes } from './local-tasks-tool-requests-
|
||||
import { registerLocalTaskPackageRequestRoutes } from './local-tasks-package-requests-api.js';
|
||||
import { registerLocalTaskControlRoutes } from './local-tasks-control-api.js';
|
||||
import { registerLocalTaskStreamRoutes } from './local-tasks-stream-api.js';
|
||||
import { registerLocalTaskMovementHistoryRoutes } from './local-tasks-movement-history-api.js';
|
||||
|
||||
export interface LocalTasksApiOptions {
|
||||
repo: Repository;
|
||||
@ -69,6 +70,15 @@ export interface LocalTasksApiOptions {
|
||||
* approving a package request fails with 400 (feature disabled).
|
||||
*/
|
||||
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
|
||||
/**
|
||||
* LLM 選択 Phase 1: タスク作成 / PATCH で llmWorkerId・llmEffort を
|
||||
* validateLlmSelection (llm-workers-api.ts) にかける際の worker 一覧。
|
||||
* Called per request so config changes take effect without a restart.
|
||||
* When unset, validation runs against an empty worker list (=常に
|
||||
* 「指定されたワーカーは存在しません」で拒否 — worker 選択 UI が無い
|
||||
* 旧来のテスト/デプロイでは llmWorkerId/llmEffort を送らない前提)。
|
||||
*/
|
||||
getLlmWorkers?: () => import('../config.js').WorkerDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -98,4 +108,5 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
||||
registerLocalTaskCommentsRoutes(app, deps);
|
||||
registerLocalTaskControlRoutes(app, deps);
|
||||
registerLocalTaskStreamRoutes(app, deps);
|
||||
registerLocalTaskMovementHistoryRoutes(app, deps);
|
||||
}
|
||||
|
||||
@ -204,6 +204,13 @@ export function registerLocalTaskCommentsRoutes(app: Application, deps: LocalTas
|
||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||
spaceId: task!.spaceId ?? null,
|
||||
// LLM 選択 Phase 1: タスクの sticky pin をフォローアップコメントで
|
||||
// 生成される job にも引き継ぐ。PATCH 経由の変更は
|
||||
// updateJobsLlmSelectionForTask が既存 queued/retry job に反映するが、
|
||||
// ここでの createJobIfNoPending は「新規生成 or まだ pending が無い」
|
||||
// ケースを担うので、task の現在値をそのまま渡す。
|
||||
requiredWorkerId: task!.llmWorkerId ?? null,
|
||||
reasoningEffort: task!.llmEffort ?? null,
|
||||
}, { blockOnToolRequestPause: true });
|
||||
// Blocked by a tool/package-approval pause → return BEFORE persisting the
|
||||
// comment so a rejected post leaves no orphan comment tied to no job.
|
||||
|
||||
@ -114,6 +114,11 @@ export function registerLocalTaskControlRoutes(app: Application, deps: LocalTask
|
||||
visibilityScopeOrgId: task!.visibilityScopeOrgId,
|
||||
browserSessionProfileId: task!.browserSessionProfileId ?? null,
|
||||
spaceId: task!.spaceId ?? null,
|
||||
// タスクの sticky pin を継承する(comment 経路と同じくタスクを正とする)。
|
||||
// これを渡さないと、pin 済みタスクを継続した瞬間に profile ルーティングへ
|
||||
// 戻ってしまう (codex P1)。
|
||||
requiredWorkerId: task!.llmWorkerId ?? null,
|
||||
reasoningEffort: task!.llmEffort ?? null,
|
||||
});
|
||||
|
||||
await repo.updateLocalTask(taskId, { pieceName: piece });
|
||||
|
||||
384
src/bridge/local-tasks-crud-api.llm-selection.test.ts
Normal file
384
src/bridge/local-tasks-crud-api.llm-selection.test.ts
Normal file
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* PATCH /api/local/tasks/:id + POST /api/local/tasks — LLM 選択 Phase 1
|
||||
* (Task 11): sticky worker/effort の検証・永続化・queued/retry job 反映、
|
||||
* および作成時の atomicity(不正なら task 行そのものが作られない)。
|
||||
*
|
||||
* Coverage:
|
||||
* 1) PATCH llmWorkerId=実在 worker → 200、task に保存、queued job に伝播
|
||||
* 2) PATCH llmWorkerId=null → 解除、queued job も NULL に戻る
|
||||
* 3) PATCH llmEffort のみ(既存 llmWorkerId とペアで検証)→ 200
|
||||
* 4) PATCH llmEffort のみ(workerId 未設定タスク)→ 400
|
||||
* 5) PATCH llmWorkerId=title専用ワーカー → 400
|
||||
* 6) PATCH llmEffort=宣言外の値 → 400
|
||||
* 7) タスク作成 POST で不正 workerId → 400 かつタスク行が作られない(atomicity)
|
||||
* 8) コメント POST → 生成 job に task の sticky が乗る
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||
import type { WorkerDef } from '../config.js';
|
||||
|
||||
const WORKERS: WorkerDef[] = [
|
||||
{
|
||||
id: 'w1',
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'w1-model',
|
||||
roles: ['auto', 'fast'],
|
||||
reasoningEfforts: ['low', 'high'],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'wt',
|
||||
endpoint: 'http://localhost:11435/v1',
|
||||
model: 'wt-model',
|
||||
roles: ['title'],
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('PATCH /api/local/tasks/:id — llmWorkerId/llmEffort sticky selection', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: express.Application;
|
||||
let aliceUser: Express.User;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||
next();
|
||||
});
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
getLlmWorkers: () => WORKERS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function makeTaskWithQueuedJob(): Promise<{ taskId: number; jobId: string }> {
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||
workspacePath: join(tempDir, 'ws'),
|
||||
});
|
||||
const job = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
});
|
||||
return { taskId: task.id, jobId: job.id };
|
||||
}
|
||||
|
||||
function requiredWorkerIdOf(jobId: string): string | null {
|
||||
const row = repo.getDb()
|
||||
.prepare('SELECT required_worker_id FROM jobs WHERE id = ?')
|
||||
.get(jobId) as { required_worker_id: string | null };
|
||||
return row.required_worker_id;
|
||||
}
|
||||
|
||||
it('1) sets llmWorkerId, persists on the task, and propagates to a queued job', async () => {
|
||||
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||
});
|
||||
|
||||
it('2) clearing llmWorkerId with explicit null reverts the queued job to NULL', async () => {
|
||||
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||
await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.task.llmWorkerId).toBeNull();
|
||||
expect(requiredWorkerIdOf(jobId)).toBeNull();
|
||||
});
|
||||
|
||||
it('2b) clearing llmWorkerId alone (no llmEffort in the body) while llmEffort is still set on the task also clears the effort (asymmetric-clear hardening)', async () => {
|
||||
// Given: worker + effort が両方セット済み(non-UI クライアントが片方だけ
|
||||
// 送ってくるケースを再現 — 一次 UI は常にペアで送るが A2A/bench/curl はそうとは限らない)。
|
||||
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||
const setBoth = await request(app)
|
||||
.patch(`/api/local/tasks/${taskId}`)
|
||||
.send({ llmWorkerId: 'w1', llmEffort: 'high' });
|
||||
expect(setBoth.status).toBe(200);
|
||||
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||
|
||||
// When: llmWorkerId のみを null で送る(llmEffort フィールドは body に含めない)。
|
||||
// 修正前は nextEffort が旧値 'high' のまま残り、effort-without-worker として 400 になっていた。
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: null });
|
||||
|
||||
// Then: 200 で成功し、worker/effort の両方が null に揃って解除される。
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.task.llmWorkerId).toBeNull();
|
||||
expect(res.body.task.llmEffort).toBeNull();
|
||||
expect(requiredWorkerIdOf(jobId)).toBeNull();
|
||||
});
|
||||
|
||||
it('3) sets llmEffort alone, validated as a pair against the task\'s existing llmWorkerId', async () => {
|
||||
const { taskId } = await makeTaskWithQueuedJob();
|
||||
const setWorker = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1' });
|
||||
expect(setWorker.status).toBe(200);
|
||||
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||
expect(res.body.task.llmEffort).toBe('high');
|
||||
});
|
||||
|
||||
it('4) rejects llmEffort alone when the task has no llmWorkerId (pair invalid)', async () => {
|
||||
const { taskId } = await makeTaskWithQueuedJob();
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmEffort: 'high' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('5) rejects a title-only worker (no pinnable execution role)', async () => {
|
||||
const { taskId } = await makeTaskWithQueuedJob();
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'wt' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('6) rejects an effort not declared by the worker', async () => {
|
||||
const { taskId } = await makeTaskWithQueuedJob();
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'bogus' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('does not touch llmWorkerId/llmEffort when neither field is present in the PATCH body', async () => {
|
||||
const { taskId, jobId } = await makeTaskWithQueuedJob();
|
||||
await request(app).patch(`/api/local/tasks/${taskId}`).send({ llmWorkerId: 'w1', llmEffort: 'high' });
|
||||
const res = await request(app).patch(`/api/local/tasks/${taskId}`).send({ title: 'renamed' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||
expect(res.body.task.llmEffort).toBe('high');
|
||||
expect(requiredWorkerIdOf(jobId)).toBe('w1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks — llmWorkerId/llmEffort validated before insert (atomicity)', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: express.Application;
|
||||
let aliceUser: Express.User;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-create-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||
next();
|
||||
});
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
getLlmWorkers: () => WORKERS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('7) rejects an unknown llmWorkerId with 400 and creates no task row', async () => {
|
||||
const countBefore = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c;
|
||||
const res = await request(app).post('/api/local/tasks').send({
|
||||
body: 'hello', piece: 'chat', llmWorkerId: 'does-not-exist',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const countAfter = (repo.getDb().prepare('SELECT COUNT(*) AS c FROM local_tasks').get() as { c: number }).c;
|
||||
expect(countAfter).toBe(countBefore);
|
||||
});
|
||||
|
||||
it('accepts a valid llmWorkerId/llmEffort pair at creation and stamps the spawned job', async () => {
|
||||
const res = await request(app).post('/api/local/tasks').send({
|
||||
body: 'hello', piece: 'chat', llmWorkerId: 'w1', llmEffort: 'low',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.task.llmWorkerId).toBe('w1');
|
||||
expect(res.body.task.llmEffort).toBe('low');
|
||||
const jobRow = repo.getDb()
|
||||
.prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?')
|
||||
.get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null };
|
||||
expect(jobRow.required_worker_id).toBe('w1');
|
||||
expect(jobRow.reasoning_effort).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/local/tasks/:id — llmWorkerId cascade reaches unstarted subtask descendants (codex P1)', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: express.Application;
|
||||
let aliceUser: Express.User;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-subtask-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||
next();
|
||||
});
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
getLlmWorkers: () => WORKERS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function requiredWorkerIdOf(jobId: string): string | null {
|
||||
const row = repo.getDb()
|
||||
.prepare('SELECT required_worker_id FROM jobs WHERE id = ?')
|
||||
.get(jobId) as { required_worker_id: string | null };
|
||||
return row.required_worker_id;
|
||||
}
|
||||
|
||||
it('clears the pin on a queued subtask child while leaving the waiting_subtasks parent untouched', async () => {
|
||||
// Given: タスク直下ジョブが 'w1' にピン留めされ waiting_subtasks で停車中
|
||||
// (SpawnSubTask がピンを継承した queued の子を抱えている)。w1 が
|
||||
// 停滞/削除されると子は永久に claim されず、親も永久に waiting_subtasks
|
||||
// のまま — これが codex P1 のシナリオ。
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||
});
|
||||
const rootJob = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
requiredWorkerId: 'w1', reasoningEffort: 'high',
|
||||
} as any);
|
||||
await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' });
|
||||
|
||||
const subtaskJob = await repo.createJob({
|
||||
repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id,
|
||||
} as any);
|
||||
|
||||
// When: ユーザーがピンを解除する(UI の「自動に戻す」復旧操作)。
|
||||
const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Then: queued subtask child は復旧(NULL = auto)— これが修正前は
|
||||
// 'w1' のまま残り、削除済みワーカーを永遠に待ち続けていた (RED)。
|
||||
expect(requiredWorkerIdOf(subtaskJob.id)).toBeNull();
|
||||
|
||||
// And: waiting_subtasks の親自身は queued/retry ではないので不変
|
||||
// (走行中扱いの行を横から書き換えない)。
|
||||
const parentRow = repo.getDb()
|
||||
.prepare('SELECT required_worker_id, status FROM jobs WHERE id = ?')
|
||||
.get(rootJob.id) as { required_worker_id: string | null; status: string };
|
||||
expect(parentRow.status).toBe('waiting_subtasks');
|
||||
expect(parentRow.required_worker_id).toBe('w1');
|
||||
});
|
||||
|
||||
it('cascades through a 2-level-deep subtask (grandchild) as well', async () => {
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||
});
|
||||
const rootJob = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
requiredWorkerId: 'w1', reasoningEffort: 'high',
|
||||
} as any);
|
||||
await repo.updateJob(rootJob.id, { status: 'waiting_subtasks' });
|
||||
|
||||
const childJob = await repo.createJob({
|
||||
repo: `subtask/${rootJob.id}`, issueNumber: 1, instruction: 'sub work',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: rootJob.id,
|
||||
} as any);
|
||||
await repo.updateJob(childJob.id, { status: 'waiting_subtasks' });
|
||||
|
||||
const grandchildJob = await repo.createJob({
|
||||
repo: `subtask/${childJob.id}`, issueNumber: 1, instruction: 'sub sub work',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
requiredWorkerId: 'w1', reasoningEffort: 'high', parentJobId: childJob.id,
|
||||
} as any);
|
||||
|
||||
const res = await request(app).patch(`/api/local/tasks/${task.id}`).send({ llmWorkerId: null });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(requiredWorkerIdOf(grandchildJob.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/local/tasks/:id/comments — inherits the task sticky selection into the spawned job', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let app: express.Application;
|
||||
let aliceUser: Express.User;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'lt-llm-sel-comment-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const real = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
|
||||
aliceUser = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: Express.User }).user = aliceUser;
|
||||
next();
|
||||
});
|
||||
mountLocalTasksApi(app, {
|
||||
repo,
|
||||
worktreeDir: join(tempDir, 'workspaces'),
|
||||
getLlmWorkers: () => WORKERS,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('8) a comment on a task pinned to w1 spawns a job with required_worker_id=w1', async () => {
|
||||
const task = await repo.createLocalTask({
|
||||
title: 't', body: 'b', ownerId: aliceUser.id, visibility: 'private',
|
||||
workspacePath: join(tempDir, 'ws'), llmWorkerId: 'w1', llmEffort: 'high',
|
||||
});
|
||||
// Prior job already terminal so the comment spawns a NEW job (not reuse).
|
||||
const prev = await repo.createJob({
|
||||
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
|
||||
ownerId: aliceUser.id, visibility: 'private', visibilityScopeOrgId: null,
|
||||
});
|
||||
await repo.updateJob(prev.id, { status: 'succeeded' });
|
||||
|
||||
const res = await request(app).post(`/api/local/tasks/${task.id}/comments`).send({ body: 'again', author: 'user' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.jobId).not.toBe(prev.id);
|
||||
|
||||
const jobRow = repo.getDb()
|
||||
.prepare('SELECT required_worker_id, reasoning_effort FROM jobs WHERE id = ?')
|
||||
.get(res.body.jobId) as { required_worker_id: string | null; reasoning_effort: string | null };
|
||||
expect(jobRow.required_worker_id).toBe('w1');
|
||||
expect(jobRow.reasoning_effort).toBe('high');
|
||||
});
|
||||
});
|
||||
@ -10,6 +10,7 @@ import { buildTitleFallback } from '../title-generation.js';
|
||||
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
|
||||
import { spaceRunsDir } from '../spaces/runtime-paths.js';
|
||||
import { type LocalTasksDeps, makeDynamicJson } from './local-tasks-shared.js';
|
||||
import { validateLlmSelection } from './llm-workers-api.js';
|
||||
|
||||
/**
|
||||
* Core task lifecycle CRUD: list / create / detail / patch / delete.
|
||||
@ -82,6 +83,27 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
browserSessionProfileId = n;
|
||||
}
|
||||
|
||||
// LLM 選択 Phase 1: タスク作成時の pin。CreateTaskDialog (Task 13) が
|
||||
// llmWorkerId/llmEffort を送る想定。insert前にvalidateLlmSelectionで
|
||||
// 検証し、不正なら「タスクは作られない」原子性を保つ(fail-before-insert)。
|
||||
const rawLlmWorkerId = req.body?.llmWorkerId;
|
||||
const rawLlmEffort = req.body?.llmEffort;
|
||||
if (rawLlmWorkerId !== undefined && rawLlmWorkerId !== null && typeof rawLlmWorkerId !== 'string') {
|
||||
res.status(400).json({ error: 'llmWorkerId must be a string or null' });
|
||||
return;
|
||||
}
|
||||
if (rawLlmEffort !== undefined && rawLlmEffort !== null && typeof rawLlmEffort !== 'string') {
|
||||
res.status(400).json({ error: 'llmEffort must be a string or null' });
|
||||
return;
|
||||
}
|
||||
const llmWorkerId: string | null = (typeof rawLlmWorkerId === 'string' && rawLlmWorkerId.trim() !== '') ? rawLlmWorkerId : null;
|
||||
const llmEffort: string | null = (typeof rawLlmEffort === 'string' && rawLlmEffort.trim() !== '') ? rawLlmEffort : null;
|
||||
const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], llmWorkerId, llmEffort);
|
||||
if (!llmValidation.ok) {
|
||||
res.status(400).json({ error: llmValidation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const userTitle = (body.title ?? '').trim();
|
||||
const rawPiece = (body.piece ?? 'auto').trim();
|
||||
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
||||
@ -153,6 +175,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
visibilityScopeOrgId: effectiveScopeOrgId,
|
||||
browserSessionProfileId,
|
||||
options: taskOptions,
|
||||
llmWorkerId,
|
||||
llmEffort,
|
||||
});
|
||||
|
||||
// 実効スペースを解決し、mode に応じてワークスペースパスを決める。
|
||||
@ -224,6 +248,8 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
browserSessionProfileId: task.browserSessionProfileId ?? null,
|
||||
spaceId: task.spaceId ?? null,
|
||||
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
|
||||
requiredWorkerId: task.llmWorkerId ?? null,
|
||||
reasoningEffort: task.llmEffort ?? null,
|
||||
});
|
||||
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
|
||||
|
||||
@ -265,7 +291,14 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||
if (!checkTaskOwnership(req, res, task)) return;
|
||||
|
||||
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
||||
const updates: {
|
||||
title?: string;
|
||||
titleSource?: 'user';
|
||||
visibility?: 'private' | 'org' | 'public';
|
||||
visibilityScopeOrgId?: string | null;
|
||||
llmWorkerId?: string | null;
|
||||
llmEffort?: string | null;
|
||||
} = {};
|
||||
if (req.body.title !== undefined) {
|
||||
if (typeof req.body.title !== 'string') {
|
||||
res.status(400).json({ error: 'title must be a string' }); return;
|
||||
@ -298,6 +331,46 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
if (updates.visibility && updates.visibility !== 'org') {
|
||||
updates.visibilityScopeOrgId = null;
|
||||
}
|
||||
|
||||
// LLM 選択 Phase 1: sticky worker/effort の部分 PATCH。片方だけ送られた
|
||||
// 場合は、もう片方をタスクの既存値と組み合わせて「ペアとして」検証する
|
||||
// (例: effort だけ送ると既に pin 済みの workerId と組み合わせて妥当性を
|
||||
// 見る — effort 単体の妥当性は worker 抜きでは判定できないため)。
|
||||
// 明示 null は解除(ワーカー未選択に戻し、従来の profile ルーティングへ)。
|
||||
const hasWorkerIdField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmWorkerId');
|
||||
const hasEffortField = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'llmEffort');
|
||||
if (hasWorkerIdField || hasEffortField) {
|
||||
if (hasWorkerIdField && req.body.llmWorkerId !== null && typeof req.body.llmWorkerId !== 'string') {
|
||||
res.status(400).json({ error: 'llmWorkerId must be a string or null' }); return;
|
||||
}
|
||||
if (hasEffortField && req.body.llmEffort !== null && typeof req.body.llmEffort !== 'string') {
|
||||
res.status(400).json({ error: 'llmEffort must be a string or null' }); return;
|
||||
}
|
||||
const nextWorkerId: string | null = hasWorkerIdField
|
||||
? ((typeof req.body.llmWorkerId === 'string' && req.body.llmWorkerId.trim() !== '') ? req.body.llmWorkerId : null)
|
||||
: (task!.llmWorkerId ?? null);
|
||||
let nextEffort: string | null = hasEffortField
|
||||
? ((typeof req.body.llmEffort === 'string' && req.body.llmEffort.trim() !== '') ? req.body.llmEffort : null)
|
||||
: (task!.llmEffort ?? null);
|
||||
// ワーカーが「このリクエストで」明示的に null 指定された場合(解除)、
|
||||
// 既存の effort が残っていると「effort だけがワーカー無しで残る」不整合
|
||||
// な組み合わせになり、後段の validateLlmSelection が effort-without-worker
|
||||
// として 400 を返してしまう。ワーカー解除は常に成功すべき操作なので、
|
||||
// effort も道連れで null にする(バリデーション前)。
|
||||
// 注意: hasWorkerIdField で「このリクエストが worker フィールドに触れたか」
|
||||
// を見る必要がある —— worker フィールド自体が未送信で、たまたま既存の
|
||||
// task に worker が無い(かつ effort だけを新規に送った)ケースは
|
||||
// 「clear」ではなく単なる effort-without-worker の不正入力なので、
|
||||
// 引き続き 400 になるべき(ケース4のテストが固定している)。
|
||||
if (hasWorkerIdField && nextWorkerId == null) nextEffort = null;
|
||||
const llmValidation = validateLlmSelection(opts.getLlmWorkers?.() ?? [], nextWorkerId, nextEffort);
|
||||
if (!llmValidation.ok) {
|
||||
res.status(400).json({ error: llmValidation.error }); return;
|
||||
}
|
||||
updates.llmWorkerId = nextWorkerId;
|
||||
updates.llmEffort = nextEffort;
|
||||
}
|
||||
|
||||
await repo.updateLocalTask(taskId, updates);
|
||||
const refreshed = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||
if ((updates.visibility !== undefined || updates.visibilityScopeOrgId !== undefined) && refreshed) {
|
||||
@ -306,6 +379,16 @@ export function registerLocalTaskCrudRoutes(app: Application, deps: LocalTasksDe
|
||||
visibilityScopeOrgId: refreshed.visibilityScopeOrgId ?? null,
|
||||
});
|
||||
}
|
||||
// Sticky ジョブ反映: PATCH 直前に生成された queued/retry ジョブ(例えば
|
||||
// 数瞬前のコメントで作られた分)が古い(または未選択の)pin を持ったまま
|
||||
// にならないよう、確定した選択を即座に伝播する。ワーカーが消えた場合の
|
||||
// 解除(null)も同じ経路で待機中ジョブへ届き、通常ルーティングに戻る。
|
||||
if ((updates.llmWorkerId !== undefined || updates.llmEffort !== undefined) && refreshed) {
|
||||
await repo.updateJobsLlmSelectionForTask(taskId, {
|
||||
workerId: refreshed.llmWorkerId ?? null,
|
||||
effort: refreshed.llmEffort ?? null,
|
||||
});
|
||||
}
|
||||
res.json({ task: refreshed });
|
||||
} catch (err) {
|
||||
logger.error(`Patch local task API error: ${err}`);
|
||||
|
||||
47
src/bridge/local-tasks-movement-history-api.test.ts
Normal file
47
src/bridge/local-tasks-movement-history-api.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { readMovementHistory } from './local-tasks-movement-history-api.js';
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
const event = (kind: string, payload: Record<string, unknown>, seq: number) => JSON.stringify({
|
||||
v: 1,
|
||||
ts: `2026-07-12T00:00:0${seq}.000Z`,
|
||||
seq,
|
||||
eventId: `e${seq}`,
|
||||
runId: 'run-1',
|
||||
movement: 'verify',
|
||||
iteration: 2,
|
||||
kind,
|
||||
payload,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('readMovementHistory', () => {
|
||||
it('returns only movement events and strips free-form retry reasons', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'movement-history-'));
|
||||
dirs.push(dir);
|
||||
const path = join(dir, 'events.jsonl');
|
||||
writeFileSync(path, [
|
||||
event('movement_start', { instruction: 'secret' }, 1),
|
||||
event('tool_call', { args: { token: 'secret' } }, 2),
|
||||
event('llm_call_retry', { attempt: 2, maxAttempts: 3, reason: 'Bearer secret', errorClass: 'http', httpStatus: 503, delayMs: 500 }, 3),
|
||||
event('movement_complete', { next: 'done', outputPreview: 'private output' }, 4),
|
||||
].join('\n'));
|
||||
|
||||
const result = await readMovementHistory(path);
|
||||
expect(result.map(item => item.kind)).toEqual(['movement_start', 'llm_call_retry', 'movement_complete']);
|
||||
expect(result[1]?.payload).toEqual({ attempt: 2, maxAttempts: 3, errorClass: 'http', httpStatus: 503, delayMs: 500 });
|
||||
expect(JSON.stringify(result)).not.toContain('secret');
|
||||
expect(JSON.stringify(result)).not.toContain('private output');
|
||||
});
|
||||
|
||||
it('returns an empty history for a missing log', async () => {
|
||||
await expect(readMovementHistory('/does/not/exist/events.jsonl')).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
108
src/bridge/local-tasks-movement-history-api.ts
Normal file
108
src/bridge/local-tasks-movement-history-api.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { type Application, type Request, type Response } from 'express';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { logger } from '../logger.js';
|
||||
import { parseEventLine } from '../progress/event-log.js';
|
||||
import { logRoot } from '../spaces/runtime-paths.js';
|
||||
import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
|
||||
import { type LocalTasksDeps } from './local-tasks-shared.js';
|
||||
import { parseTaskId } from './validation.js';
|
||||
|
||||
const MAX_HISTORY_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_HISTORY_EVENTS = 2_000;
|
||||
const ALLOWED_KINDS = new Set(['movement_start', 'movement_complete', 'llm_call_retry']);
|
||||
|
||||
export interface MovementHistoryEvent {
|
||||
eventId: string;
|
||||
ts: string;
|
||||
seq: number;
|
||||
line: number;
|
||||
runId: string;
|
||||
kind: 'movement_start' | 'movement_complete' | 'llm_call_retry';
|
||||
movement: string | null;
|
||||
iteration: number | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const readTailLines = async (path: string): Promise<string[]> => {
|
||||
let file;
|
||||
try {
|
||||
file = await open(path, 'r');
|
||||
const size = (await file.stat()).size;
|
||||
const start = Math.max(0, size - MAX_HISTORY_BYTES);
|
||||
const buffer = Buffer.alloc(size - start);
|
||||
await file.read(buffer, 0, buffer.length, start);
|
||||
const text = buffer.toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
if (start > 0) lines.shift();
|
||||
return lines;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
} finally {
|
||||
await file?.close();
|
||||
}
|
||||
};
|
||||
|
||||
const safePayload = (kind: string, payload: unknown): Record<string, unknown> => {
|
||||
const source = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {};
|
||||
if (kind === 'movement_complete') {
|
||||
return {
|
||||
next: typeof source.next === 'string' ? source.next : null,
|
||||
waitReason: typeof source.waitReason === 'string' ? source.waitReason : null,
|
||||
};
|
||||
}
|
||||
if (kind === 'llm_call_retry') {
|
||||
return {
|
||||
attempt: typeof source.attempt === 'number' ? source.attempt : null,
|
||||
maxAttempts: typeof source.maxAttempts === 'number' ? source.maxAttempts : null,
|
||||
errorClass: typeof source.errorClass === 'string' ? source.errorClass : null,
|
||||
httpStatus: typeof source.httpStatus === 'number' ? source.httpStatus : null,
|
||||
delayMs: typeof source.delayMs === 'number' ? source.delayMs : null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const readMovementHistory = async (path: string): Promise<MovementHistoryEvent[]> => {
|
||||
const events: MovementHistoryEvent[] = [];
|
||||
const lines = await readTailLines(path);
|
||||
for (let line = 0; line < lines.length; line++) {
|
||||
const raw = lines[line];
|
||||
if (!raw?.trim()) continue;
|
||||
const parsed = parseEventLine(raw);
|
||||
if (parsed.kind !== 'ok' || !ALLOWED_KINDS.has(parsed.event.kind)) continue;
|
||||
events.push({
|
||||
eventId: parsed.event.eventId,
|
||||
ts: parsed.event.ts,
|
||||
seq: parsed.event.seq,
|
||||
line,
|
||||
runId: parsed.event.runId,
|
||||
kind: parsed.event.kind as MovementHistoryEvent['kind'],
|
||||
movement: parsed.event.movement ?? null,
|
||||
iteration: parsed.event.iteration ?? null,
|
||||
payload: safePayload(parsed.event.kind, parsed.event.payload),
|
||||
});
|
||||
}
|
||||
return events.slice(-MAX_HISTORY_EVENTS);
|
||||
};
|
||||
|
||||
export const registerLocalTaskMovementHistoryRoutes = (app: Application, deps: LocalTasksDeps): void => {
|
||||
app.get('/api/local/tasks/:taskId/movement-history', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const taskId = parseTaskId(req.params.taskId);
|
||||
if (taskId === null) {
|
||||
res.status(400).json({ error: 'Invalid task ID' });
|
||||
return;
|
||||
}
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const task = await deps.repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||||
if (!canViewTask(req, res, task, resolveSpaceAccess(deps.repo, task, viewer))) return;
|
||||
const path = join(logRoot(task!), 'events.jsonl');
|
||||
res.json({ events: await readMovementHistory(path) });
|
||||
} catch (err) {
|
||||
logger.error(`Movement history API error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to fetch movement history' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import request from 'supertest';
|
||||
@ -28,6 +28,7 @@ describe('createCoreServer subsystem wiring', () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MCP_ENCRYPTION_KEY;
|
||||
delete process.env.AAO_CONFIG;
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@ -41,6 +42,12 @@ describe('createCoreServer subsystem wiring', () => {
|
||||
// MCP admin router is mounted (not a 404 from the express fallthrough).
|
||||
const res = await request(app).get('/api/mcp/servers');
|
||||
expect(res.status).not.toBe(404);
|
||||
// Issue #785: even with SSH disabled, the console/status poll must not
|
||||
// 404 — a stub router still answers 200 active=false so the UI's 5s
|
||||
// poll doesn't spam DevTools with unsuppressible network errors.
|
||||
const statusRes = await request(app).get('/api/local/tasks/some-task/console/status');
|
||||
expect(statusRes.status).toBe(200);
|
||||
expect(statusRes.body).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it('does NOT mount MCP routers when the key is absent', async () => {
|
||||
@ -51,6 +58,20 @@ describe('createCoreServer subsystem wiring', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('mounts the inactive console status stub when SSH is enabled but the key is absent', async () => {
|
||||
const configPath = join(tempDir, 'config.yaml');
|
||||
writeFileSync(configPath, 'ssh:\n enabled: true\n');
|
||||
process.env.AAO_CONFIG = configPath;
|
||||
delete process.env.MCP_ENCRYPTION_KEY;
|
||||
|
||||
const { app, sshConsole } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
|
||||
expect(sshConsole).toBeNull();
|
||||
|
||||
const statusRes = await request(app).get('/api/local/tasks/some-task/console/status');
|
||||
expect(statusRes.status).toBe(200);
|
||||
expect(statusRes.body).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it('boots and serves /api/local/tasks regardless of MCP key', async () => {
|
||||
const { app } = createCoreServer({ repo, worktreeDir: join(tempDir, 'wt') });
|
||||
const res = await request(app).get('/api/local/tasks');
|
||||
|
||||
@ -18,6 +18,7 @@ import { createBrowserSessionApi } from './browser-session-api.js';
|
||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||
import { createDelegateRunsRouter } from './delegate-runs-api.js';
|
||||
import { createUsageRouter } from './usage-api.js';
|
||||
import { createLlmWorkersRouter } from './llm-workers-api.js';
|
||||
import { SessionManager } from '../engine/browser-session.js';
|
||||
import { setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||
import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js';
|
||||
@ -62,6 +63,7 @@ import { attachConsoleWs } from './console-ws-api.js';
|
||||
import type { GatewayMountHandle } from './gateway-mount.js';
|
||||
import { mountBridgeGateway } from './bridge-gateway-mount.js';
|
||||
import { setupA2aSubsystem } from './a2a-subsystem.js';
|
||||
import { setupChatSubsystem } from './chat/chat-subsystem.js';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { X509Certificate } from 'crypto';
|
||||
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
|
||||
@ -97,6 +99,9 @@ export interface CoreServerOptions {
|
||||
pushService?: import('../push-service.js').PushService | null;
|
||||
/** Notifications V2 — VAPID key store. Required when pushService is set. */
|
||||
vapidStore?: import('../vapid-store.js').VapidKeyStore | null;
|
||||
/** Workspace webhook notifications (issue #797). null disables test-send
|
||||
* (route returns 503) but the CRUD routes remain mounted. */
|
||||
webhookService?: import('../webhook-service.js').WebhookDeliveryService | null;
|
||||
/**
|
||||
* TCP port the bridge will listen on. Forwarded to admin endpoints
|
||||
* (e.g. /api/admin/gateway/status) so the UI can label the gateway
|
||||
@ -279,6 +284,10 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
: () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
|
||||
// Package-request approvals install into the task's space overlay.
|
||||
getPythonPackagesConfig: () => loadConfig().pythonPackages,
|
||||
// LLM 選択 Phase 1: タスク作成/PATCH の validateLlmSelection に渡す worker 一覧。
|
||||
getLlmWorkers: opts.configManager
|
||||
? () => opts.configManager!.getConfig().provider?.workers ?? []
|
||||
: () => loadConfig().provider?.workers ?? [],
|
||||
});
|
||||
|
||||
// --- Local files API ---
|
||||
@ -291,8 +300,20 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// 横断(全スペース)カレンダー: GET /api/calendar/cross
|
||||
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
|
||||
|
||||
// LLM ワーカー選択 API(タスクへの pin 用): GET /api/llm/workers
|
||||
// 実行ロール(auto/fast/quality)を持つワーカーのみ・秘密値非返却。
|
||||
// requireAuth は auth-subsystem.ts 側(認証有効時のみ)で配線。
|
||||
if (opts.configManager) {
|
||||
app.use('/api/llm/workers', createLlmWorkersRouter(opts.configManager));
|
||||
}
|
||||
|
||||
// --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
|
||||
setupA2aSubsystem(app, { repo, authActive });
|
||||
const { limiter: a2aLimiter } = setupA2aSubsystem(app, { repo, authActive });
|
||||
|
||||
// --- External chat connector (see chat/chat-subsystem.ts; no-op unless chat.enabled) ---
|
||||
// Reuses the A2A limiter instance above so governance counters are shared
|
||||
// per-grant across the JSON-RPC A2A path and the chat path.
|
||||
setupChatSubsystem(app, { repo, authActive, a2aLimiter });
|
||||
|
||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||
mountSubtaskFilesApi(app, repo);
|
||||
@ -465,6 +486,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
|
||||
authActive,
|
||||
getPythonPackagesConfig: () => loadConfig().pythonPackages,
|
||||
webhookService: opts.webhookService ?? null,
|
||||
}));
|
||||
|
||||
// BackendStatusRegistry singleton — probes every configured worker
|
||||
|
||||
@ -118,6 +118,19 @@ describe('setup-api: GET /api/setup/status', () => {
|
||||
expect(res.body.authActive).toBe(true);
|
||||
expect(res.body.tokenRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('a2aEnabled=false by default (no a2a section)', async () => {
|
||||
const { app } = makeApp(UNCONFIGURED);
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
// The UI hides the per-user A2A Delegations settings section unless this is true.
|
||||
expect(res.body.a2aEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('a2aEnabled=true when a2a.enabled is set in config', async () => {
|
||||
const { app } = makeApp('config_version: 2\na2a:\n enabled: true\n');
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.body.a2aEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-api: token gate on /api/setup/apply', () => {
|
||||
|
||||
@ -302,10 +302,20 @@ export function mountSetupApi(
|
||||
try {
|
||||
const needsSetup = await computeNeedsSetup(configManager);
|
||||
const tokenRequired = !authActive && readSetupToken(dataDir) !== null;
|
||||
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired });
|
||||
// a2a is off by default and mounts its /api/local/a2a/delegations route only
|
||||
// when enabled. Expose the flag so the UI can hide the per-user A2A Delegations
|
||||
// settings section instead of showing a load error against the missing route.
|
||||
const a2aEnabled = ((configManager.getConfig() as { a2a?: { enabled?: boolean } })?.a2a?.enabled) === true;
|
||||
// chat is off by default and mounts its /api/admin/chat/bindings route only
|
||||
// when enabled (see chat-subsystem.ts). Expose the flag the same way a2a
|
||||
// does so the UI can hide the admin Chat Connectors settings section
|
||||
// instead of showing a load error against the missing route.
|
||||
const chatEnabled = ((configManager.getConfig() as { chat?: { enabled?: boolean } })?.chat?.enabled) === true;
|
||||
res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled, chatEnabled });
|
||||
} catch (e) {
|
||||
logger.warn(`[setup-api] status failed: ${String(e)}`);
|
||||
res.status(500).json({ needsSetup: false, authActive, error: 'status unavailable' });
|
||||
// Fail-closed: on error the UI treats a2a/chat as disabled and hides the sections.
|
||||
res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, chatEnabled: false, error: 'status unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -191,6 +191,33 @@ describe('space-api calendar', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminder validation', () => {
|
||||
it('rejects removing the start time while an existing reminder remains', async () => {
|
||||
const created = await request(appAs(owner))
|
||||
.post(`/api/local/spaces/${spaceId}/calendar/events`)
|
||||
.send({ date: '2026-07-01', title: 'remind me', time: '09:00', reminder_minutes: 10 });
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const patched = await request(appAs(owner))
|
||||
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
|
||||
.send({ time: null });
|
||||
expect(patched.status).toBe(400);
|
||||
expect(patched.body.error).toContain('reminder_minutes requires a start time');
|
||||
});
|
||||
|
||||
it('allows removing a reminder while changing the event to all-day', async () => {
|
||||
const created = await request(appAs(owner))
|
||||
.post(`/api/local/spaces/${spaceId}/calendar/events`)
|
||||
.send({ date: '2026-07-01', title: 'clear reminder', time: '09:00', reminder_minutes: 10 });
|
||||
const patched = await request(appAs(owner))
|
||||
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
|
||||
.send({ time: null, reminder_minutes: null });
|
||||
expect(patched.status).toBe(200);
|
||||
expect(patched.body.time).toBeNull();
|
||||
expect(patched.body.reminderMinutes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-day events via HTTP', () => {
|
||||
it('creates a span and surfaces it on every covered day in the month view', async () => {
|
||||
const created = await request(appAs(owner))
|
||||
|
||||
@ -69,6 +69,64 @@ describe('space-api', () => {
|
||||
expect(list.find((s: any) => s.id === created.id)).toBeUndefined(); // archived hidden by default
|
||||
});
|
||||
|
||||
it('PATCH display stores favorite/hidden and hiding clears favorite', async () => {
|
||||
const created = (await request(app).post('/api/local/spaces').send({ title: '整理対象' })).body;
|
||||
|
||||
const fav = await request(app)
|
||||
.patch(`/api/local/spaces/${created.id}/display`)
|
||||
.send({ favorite: true });
|
||||
expect(fav.status).toBe(200);
|
||||
expect(fav.body).toEqual({ favorite: true, hidden: false });
|
||||
|
||||
const hidden = await request(app)
|
||||
.patch(`/api/local/spaces/${created.id}/display`)
|
||||
.send({ hidden: true });
|
||||
expect(hidden.status).toBe(200);
|
||||
expect(hidden.body).toEqual({ favorite: false, hidden: true });
|
||||
|
||||
const list = (await request(app).get('/api/local/spaces')).body;
|
||||
const row = list.find((s: any) => s.id === created.id);
|
||||
expect(row.favorite).toBe(false);
|
||||
expect(row.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('display prefs are per-user and do not affect other viewers', async () => {
|
||||
const owner = repo.createUser({ email: 'display-owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
const viewer = repo.createUser({ email: 'display-viewer@example.com', name: 'Viewer', role: 'user', status: 'active' });
|
||||
const space = await repo.createSpace({
|
||||
kind: 'case', title: '共有表示設定', description: '', ownerId: owner.id,
|
||||
visibility: 'public', visibilityScopeOrgId: null, brandColor: null,
|
||||
});
|
||||
|
||||
const makeAppAs = (userId: string) => {
|
||||
const a = express();
|
||||
a.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user', orgIds: [] }; next(); });
|
||||
a.use('/api/local/spaces', createSpaceApi({
|
||||
repo, dataRoot: join(dir, 'data', 'users'), worktreeDir: join(dir, 'wt'), authActive: true,
|
||||
}));
|
||||
return a;
|
||||
};
|
||||
|
||||
const ownerApp = makeAppAs(owner.id);
|
||||
const viewerApp = makeAppAs(viewer.id);
|
||||
const fav = await request(ownerApp).patch(`/api/local/spaces/${space.id}/display`).send({ favorite: true });
|
||||
expect(fav.status).toBe(200);
|
||||
|
||||
const ownerList = (await request(ownerApp).get('/api/local/spaces')).body;
|
||||
const viewerList = (await request(viewerApp).get('/api/local/spaces')).body;
|
||||
expect(ownerList.find((s: any) => s.id === space.id).favorite).toBe(true);
|
||||
expect(viewerList.find((s: any) => s.id === space.id).favorite).toBe(false);
|
||||
});
|
||||
|
||||
it('PATCH display returns 404 for spaces the viewer cannot see', async () => {
|
||||
const other = await repo.createSpace({
|
||||
kind: 'case', title: 'secret-display', description: '', ownerId: 'user-2',
|
||||
visibility: 'private', visibilityScopeOrgId: null, brandColor: null,
|
||||
});
|
||||
const res = await request(app).patch(`/api/local/spaces/${other.id}/display`).send({ favorite: true });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('lists + serves files from the space persistent workspace and rejects traversal', async () => {
|
||||
const created = (await request(app).post('/api/local/spaces').send({ title: '案件F' })).body;
|
||||
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
|
||||
|
||||
@ -11,6 +11,7 @@ import { registerSpaceInviteRoutes } from './space-invite-api.js';
|
||||
import { registerSpaceAppShareRoutes } from './space-app-share-api.js';
|
||||
import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js';
|
||||
import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js';
|
||||
import { registerSpaceWebhooksRoutes } from './space-webhooks-api.js';
|
||||
import { viewerOf } from './space-viewer.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
|
||||
@ -39,6 +40,8 @@ export interface SpaceApiDeps {
|
||||
uploadLimitMb?: number;
|
||||
/** Per-space Python packages のライブ config(loadConfig() を都度読む getter)。未配線 = 無効扱い。 */
|
||||
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
|
||||
/** ワークスペース Webhook 通知(issue #797)— test-send 実行用。null/未配線 = 501/503 応答。 */
|
||||
webhookService?: import('../webhook-service.js').WebhookDeliveryService | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -63,7 +66,7 @@ export function createSpaceApi(deps: SpaceApiDeps): Router {
|
||||
router.get('/', async (req, res) => {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
await repo.ensurePersonalSpace(viewer.id, viewer.name ?? undefined);
|
||||
const spaces = await repo.listSpaces({ viewer });
|
||||
const spaces = await repo.listSpacesWithDisplayPrefs({ viewer });
|
||||
res.json(spaces.map(s => ({ ...s, myRole: repo.getSpaceMemberRole(s.id, viewer.id) })));
|
||||
});
|
||||
|
||||
@ -77,6 +80,28 @@ export function createSpaceApi(deps: SpaceApiDeps): Router {
|
||||
res.json({ ...space, myRole });
|
||||
});
|
||||
|
||||
router.patch("/:id/display", jsonParser, async (req, res) => {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
const space = await repo.getSpace(req.params.id, { viewer });
|
||||
if (!space) return res.status(404).json({ error: "not found" });
|
||||
|
||||
const patch: { favorite?: boolean; hidden?: boolean } = {};
|
||||
if (req.body?.favorite !== undefined) {
|
||||
if (typeof req.body.favorite !== "boolean") return res.status(400).json({ error: "favorite must be boolean" });
|
||||
patch.favorite = req.body.favorite;
|
||||
}
|
||||
if (req.body?.hidden !== undefined) {
|
||||
if (typeof req.body.hidden !== "boolean") return res.status(400).json({ error: "hidden must be boolean" });
|
||||
patch.hidden = req.body.hidden;
|
||||
}
|
||||
if (patch.favorite === undefined && patch.hidden === undefined) {
|
||||
return res.status(400).json({ error: "favorite or hidden is required" });
|
||||
}
|
||||
|
||||
const prefs = repo.updateSpaceDisplayPrefs(viewer.id, space.id, patch);
|
||||
res.json(prefs);
|
||||
});
|
||||
|
||||
// 作成(案件スペース)。DB 行 → 雛形作成。雛形失敗時は行を消してロールバック。
|
||||
router.post('/', jsonParser, async (req, res) => {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
@ -164,6 +189,9 @@ export function createSpaceApi(deps: SpaceApiDeps): Router {
|
||||
// Python パッケージ管理 GET/POST/DELETE は space-python-packages-api.ts に分離。
|
||||
registerSpacePythonPackagesRoutes(router, deps);
|
||||
|
||||
// Webhook 通知(Discord/Slack/Teams)CRUD + test-send は space-webhooks-api.ts に分離。
|
||||
registerSpaceWebhooksRoutes(router, deps);
|
||||
|
||||
// ─── スペース間資格情報コピー ──────────────────────────────────────
|
||||
// 両スペースの管理権(canManageSpace)を持つユーザーのみ利用可能。
|
||||
// 成功時に audit_log へ記録する。
|
||||
|
||||
@ -152,6 +152,9 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
||||
const multiDay = endDate != null && endDate > date;
|
||||
if (!multiDay && endTime < time) return res.status(400).json({ error: 'end_time must be on or after the start time' });
|
||||
}
|
||||
const reminderMinutes = req.body?.reminder_minutes == null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes);
|
||||
if (reminderMinutes != null && ![0, 5, 10, 30, 60].includes(reminderMinutes)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' });
|
||||
if (reminderMinutes != null && time == null) return res.status(400).json({ error: 'reminder_minutes requires a start time' });
|
||||
const ownerId = viewer.id === 'local' ? null : viewer.id;
|
||||
const ev = await repo.createCalendarEvent({
|
||||
spaceId: space.id,
|
||||
@ -160,6 +163,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
||||
endDate,
|
||||
time,
|
||||
endTime,
|
||||
reminderMinutes,
|
||||
title,
|
||||
description: req.body?.description != null ? String(req.body.description) : null,
|
||||
createdBy: 'user',
|
||||
@ -179,7 +183,7 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
||||
const eventId = Number(req.params.eventId);
|
||||
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
|
||||
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
|
||||
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null } = {};
|
||||
const patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null } = {};
|
||||
if (req.body?.date !== undefined) {
|
||||
const d = String(req.body.date);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
|
||||
@ -211,12 +215,21 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
||||
if (et != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(et)) return res.status(400).json({ error: 'end_time must be HH:MM' });
|
||||
patch.endTime = et;
|
||||
}
|
||||
if (req.body?.reminder_minutes !== undefined) {
|
||||
const value = req.body.reminder_minutes === null || req.body.reminder_minutes === '' ? null : Number(req.body.reminder_minutes);
|
||||
if (value != null && ![0, 5, 10, 30, 60].includes(value)) return res.status(400).json({ error: 'reminder_minutes must be one of 0, 5, 10, 30, 60' });
|
||||
patch.reminderMinutes = value;
|
||||
}
|
||||
// 時刻の整合は「更新後の実効値」で検証する。time/date/end_date だけを変えて
|
||||
// end_time を省略したケース(開始を遅らせる・複数日を単日に戻す等)でも、
|
||||
// 単日で終了<開始や開始時刻なしの終了時刻という不整合を確実に弾く。
|
||||
{
|
||||
const effTime = patch.time !== undefined ? patch.time : existing.time;
|
||||
const effEndTime = patch.endTime !== undefined ? patch.endTime : existing.endTime;
|
||||
const effReminderMinutes = patch.reminderMinutes !== undefined ? patch.reminderMinutes : existing.reminderMinutes;
|
||||
if (effReminderMinutes != null && effTime == null) {
|
||||
return res.status(400).json({ error: 'reminder_minutes requires a start time' });
|
||||
}
|
||||
if (effEndTime != null) {
|
||||
if (effTime == null) return res.status(400).json({ error: 'end_time requires a start time' });
|
||||
const effEnd = patch.endDate !== undefined ? patch.endDate : existing.endDate;
|
||||
@ -251,4 +264,16 @@ export function registerSpaceCalendarRoutes(router: Router, deps: SpaceApiDeps):
|
||||
await repo.deleteCalendarEvent(eventId);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// 表示中のクライアントが期限到来通知を一度だけ取得する。サーバー現在時刻で
|
||||
// 判定し、クライアントが未来時刻を指定して先取りできないようにする。
|
||||
router.post('/:id/calendar/reminders/claim', jsonParser, async (req, res) => {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
const space = await repo.getSpace(req.params.id, { viewer });
|
||||
if (!space) return res.status(404).json({ error: 'not found' });
|
||||
const current = new Date();
|
||||
const now = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')} ${String(current.getHours()).padStart(2, '0')}:${String(current.getMinutes()).padStart(2, '0')}`;
|
||||
const events = await repo.claimDueCalendarReminders(space.id, now);
|
||||
res.json({ events });
|
||||
});
|
||||
}
|
||||
|
||||
337
src/bridge/space-webhooks-api.test.ts
Normal file
337
src/bridge/space-webhooks-api.test.ts
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Multi-user integration tests for /api/local/spaces/:id/webhooks.
|
||||
*
|
||||
* Auth pattern mirrors space-api.tool-policy.test.ts: authActive=true +
|
||||
* middleware that sets req.user = <seeded user>. Roles: manager (space
|
||||
* owner), member (viewer-role member), stranger (no relation).
|
||||
*
|
||||
* SECURITY: several tests specifically assert the webhook URL never appears
|
||||
* in a response body (no `url`, no `url_enc`, no plaintext-URL-looking
|
||||
* substring) — the entire point of issue #797's non-disclosure requirement.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { createSpaceApi } from './space-api.js';
|
||||
import type { SendResult } from '../webhook-service.js';
|
||||
|
||||
const DISCORD_URL = 'https://discord.com/api/webhooks/1234567890/super-secret-token-abcXYZ';
|
||||
|
||||
function freshSetup() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webhooks-api-test-'));
|
||||
const repo = new Repository(join(dir, `${randomUUID()}.db`));
|
||||
return { repo, dir };
|
||||
}
|
||||
|
||||
interface FakeWebhookService {
|
||||
sendTest: (id: string) => Promise<SendResult>;
|
||||
}
|
||||
|
||||
function makeApp(repo: Repository, currentUser: Express.User, webhookService?: FakeWebhookService | null) {
|
||||
const app = express();
|
||||
app.use((req: any, _res: any, next: any) => {
|
||||
req.user = currentUser;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
'/',
|
||||
createSpaceApi({
|
||||
repo,
|
||||
dataRoot: tmpdir(),
|
||||
worktreeDir: tmpdir(),
|
||||
authActive: true,
|
||||
webhookService: webhookService as never,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seed(repo: Repository) {
|
||||
const manager = repo.createUser({ email: 'manager@example.com', name: 'Manager', role: 'user', status: 'active' });
|
||||
const member = repo.createUser({ email: 'member@example.com', name: 'Member', role: 'user', status: 'active' });
|
||||
const stranger = repo.createUser({ email: 'stranger@example.com', name: 'Stranger', role: 'user', status: 'active' });
|
||||
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Test Space', ownerId: manager.id, visibility: 'public' });
|
||||
await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer', invitedBy: manager.id });
|
||||
|
||||
return { manager, member, stranger, space };
|
||||
}
|
||||
|
||||
function asExpressUser(user: { id: string; role: 'admin' | 'user' }): Express.User {
|
||||
return { id: user.id, role: user.role, orgIds: [] } as unknown as Express.User;
|
||||
}
|
||||
|
||||
describe('space-webhooks-api', () => {
|
||||
let repo: Repository;
|
||||
let dir: string;
|
||||
let fixtures: Awaited<ReturnType<typeof seed>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex');
|
||||
({ repo, dir } = freshSetup());
|
||||
fixtures = await seed(repo);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createWebhook(app: express.Express, overrides: Record<string, unknown> = {}) {
|
||||
return request(app)
|
||||
.post(`/${fixtures.space.id}/webhooks`)
|
||||
.send({ provider: 'discord', label: 'Dev', url: DISCORD_URL, events: ['succeeded', 'failed'], ...overrides });
|
||||
}
|
||||
|
||||
// ── GET (list) — any viewer ─────────────────────────────────────────
|
||||
|
||||
describe('GET /:id/webhooks', () => {
|
||||
it('manager sees an empty list initially', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await request(app).get(`/${fixtures.space.id}/webhooks`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.webhooks).toEqual([]);
|
||||
});
|
||||
|
||||
it('member (viewer) can GET the list', async () => {
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
await createWebhook(managerApp);
|
||||
const memberApp = makeApp(repo, asExpressUser(fixtures.member));
|
||||
const res = await request(memberApp).get(`/${fixtures.space.id}/webhooks`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.webhooks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stranger on a private space gets 404', async () => {
|
||||
const priv = await repo.createSpace({ kind: 'case', title: 'Priv', ownerId: fixtures.manager.id, visibility: 'private' });
|
||||
const app = makeApp(repo, asExpressUser(fixtures.stranger));
|
||||
const res = await request(app).get(`/${priv.id}/webhooks`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('response never contains url, url_enc, or a plaintext-URL-looking string', async () => {
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
await createWebhook(managerApp);
|
||||
const res = await request(managerApp).get(`/${fixtures.space.id}/webhooks`);
|
||||
expect(res.status).toBe(200);
|
||||
const raw = JSON.stringify(res.body);
|
||||
expect(res.body.webhooks[0]).not.toHaveProperty('url');
|
||||
expect(res.body.webhooks[0]).not.toHaveProperty('urlEnc');
|
||||
expect(res.body.webhooks[0]).not.toHaveProperty('url_enc');
|
||||
expect(raw).not.toContain('discord.com/api/webhooks');
|
||||
expect(raw).not.toContain('super-secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST — canManageSpace only ──────────────────────────────────────
|
||||
|
||||
describe('POST /:id/webhooks', () => {
|
||||
it('manager (owner) can create a webhook; response has no url field', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).not.toHaveProperty('url');
|
||||
expect(res.body).not.toHaveProperty('urlEnc');
|
||||
expect(res.body.label).toBe('Dev');
|
||||
expect(res.body.events).toEqual(['succeeded', 'failed']);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
expect(res.body.failureCount).toBe(0);
|
||||
expect(JSON.stringify(res.body)).not.toContain('discord.com/api/webhooks');
|
||||
});
|
||||
|
||||
it('member (viewer-role) receives 403', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.member));
|
||||
const res = await createWebhook(app);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('stranger on a public space receives 403', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.stranger));
|
||||
const res = await createWebhook(app);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a non-https url with 400', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, { url: 'http://discord.com/api/webhooks/1/2' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a provider name outside the known enum with 400', async () => {
|
||||
// As of PR3 (Teams), all three WebhookProvider values (discord/slack/
|
||||
// teams) have working adapters, so there's no more "known but not yet
|
||||
// supported" provider to exercise IMPLEMENTED_PROVIDERS with. This now
|
||||
// covers the KNOWN_PROVIDERS enum check itself via a made-up name.
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, { provider: 'webex' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/must be one of/);
|
||||
});
|
||||
|
||||
it('accepts provider=slack and creates a webhook; response has no url field', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, {
|
||||
provider: 'slack',
|
||||
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.provider).toBe('slack');
|
||||
expect(res.body).not.toHaveProperty('url');
|
||||
expect(res.body).not.toHaveProperty('urlEnc');
|
||||
expect(JSON.stringify(res.body)).not.toContain('hooks.slack.com');
|
||||
});
|
||||
|
||||
it('accepts provider=teams and creates a webhook; response has no url field', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, {
|
||||
provider: 'teams',
|
||||
url: 'https://example.webhook.office.com/webhookb2/00000000-0000-0000-0000-000000000000@tenant/IncomingWebhook/abcdef/00000000',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.provider).toBe('teams');
|
||||
expect(res.body).not.toHaveProperty('url');
|
||||
expect(res.body).not.toHaveProperty('urlEnc');
|
||||
expect(JSON.stringify(res.body)).not.toContain('webhook.office.com');
|
||||
});
|
||||
|
||||
it('rejects an empty events array with 400', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, { events: [] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects an empty label with 400', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const res = await createWebhook(app, { label: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT — canManageSpace only ───────────────────────────────────────
|
||||
|
||||
describe('PUT /:id/webhooks/:webhookId', () => {
|
||||
it('manager can update label/events; GET reflects the change', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(app);
|
||||
const put = await request(app)
|
||||
.put(`/${fixtures.space.id}/webhooks/${created.body.id}`)
|
||||
.send({ label: 'Renamed', events: ['waiting_human'] });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.label).toBe('Renamed');
|
||||
expect(put.body.events).toEqual(['waiting_human']);
|
||||
expect(put.body).not.toHaveProperty('url');
|
||||
});
|
||||
|
||||
it('member receives 403 on PUT', async () => {
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(managerApp);
|
||||
const memberApp = makeApp(repo, asExpressUser(fixtures.member));
|
||||
const res = await request(memberApp)
|
||||
.put(`/${fixtures.space.id}/webhooks/${created.body.id}`)
|
||||
.send({ label: 'Hacked' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('URL re-entry via PUT does not echo the new URL back', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(app);
|
||||
const newUrl = 'https://discord.com/api/webhooks/999/new-secret-token';
|
||||
const put = await request(app)
|
||||
.put(`/${fixtures.space.id}/webhooks/${created.body.id}`)
|
||||
.send({ url: newUrl });
|
||||
expect(put.status).toBe(200);
|
||||
expect(JSON.stringify(put.body)).not.toContain('new-secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH — enable/disable ──────────────────────────────────────────
|
||||
|
||||
describe('PATCH /:id/webhooks/:webhookId', () => {
|
||||
it('manager can disable then re-enable', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(app);
|
||||
const disable = await request(app).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: false });
|
||||
expect(disable.status).toBe(200);
|
||||
expect(disable.body.enabled).toBe(false);
|
||||
|
||||
const enable = await request(app).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: true });
|
||||
expect(enable.status).toBe(200);
|
||||
expect(enable.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('member receives 403', async () => {
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(managerApp);
|
||||
const memberApp = makeApp(repo, asExpressUser(fixtures.member));
|
||||
const res = await request(memberApp).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /:id/webhooks/:webhookId', () => {
|
||||
it('manager can delete', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(app);
|
||||
const del = await request(app).delete(`/${fixtures.space.id}/webhooks/${created.body.id}`);
|
||||
expect(del.status).toBe(200);
|
||||
const list = await request(app).get(`/${fixtures.space.id}/webhooks`);
|
||||
expect(list.body.webhooks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('member receives 403', async () => {
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager));
|
||||
const created = await createWebhook(managerApp);
|
||||
const memberApp = makeApp(repo, asExpressUser(fixtures.member));
|
||||
const res = await request(memberApp).delete(`/${fixtures.space.id}/webhooks/${created.body.id}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /:id/webhooks/:webhookId/test ──────────────────────────────
|
||||
|
||||
describe('POST /:id/webhooks/:webhookId/test', () => {
|
||||
it('manager can trigger a test send (service reports success)', async () => {
|
||||
const sendTest = vi.fn(async (): Promise<SendResult> => ({ ok: true }));
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager), { sendTest });
|
||||
const created = await createWebhook(app);
|
||||
const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(sendTest).toHaveBeenCalledWith(created.body.id);
|
||||
});
|
||||
|
||||
it('reports failure from the service without leaking anything sensitive', async () => {
|
||||
const sendTest = vi.fn(async (): Promise<SendResult> => ({ ok: false, error: 'webhook rejected (404)' }));
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager), { sendTest });
|
||||
const created = await createWebhook(app);
|
||||
const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`);
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.ok).toBe(false);
|
||||
expect(JSON.stringify(res.body)).not.toContain('discord.com/api/webhooks');
|
||||
});
|
||||
|
||||
it('returns 503 when webhook delivery is disabled (no service configured)', async () => {
|
||||
const app = makeApp(repo, asExpressUser(fixtures.manager), null);
|
||||
const created = await createWebhook(app);
|
||||
const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('member receives 403 (test-send requires canManageSpace)', async () => {
|
||||
const sendTest = vi.fn(async (): Promise<SendResult> => ({ ok: true }));
|
||||
const managerApp = makeApp(repo, asExpressUser(fixtures.manager), { sendTest });
|
||||
const created = await createWebhook(managerApp);
|
||||
const memberApp = makeApp(repo, asExpressUser(fixtures.member), { sendTest });
|
||||
const res = await request(memberApp).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(sendTest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
272
src/bridge/space-webhooks-api.ts
Normal file
272
src/bridge/space-webhooks-api.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import express, { Router } from 'express';
|
||||
import { canManageSpace } from './visibility.js';
|
||||
import { viewerOf } from './space-viewer.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { encrypt, loadKeyFromEnv } from '../mcp/crypto.js';
|
||||
import type { SpaceApiDeps } from './space-api.js';
|
||||
import type { SpaceWebhookRecord, WebhookEventType, WebhookProvider } from '../db/repository.js';
|
||||
|
||||
/**
|
||||
* Per-space webhook notification CRUD + test-send (issue #797, PR1).
|
||||
*
|
||||
* GET /:id/webhooks — viewer: list (masked DTO)
|
||||
* POST /:id/webhooks — canManageSpace: create
|
||||
* PUT /:id/webhooks/:webhookId — canManageSpace: update (label/url/events/includeDetails)
|
||||
* PATCH /:id/webhooks/:webhookId — canManageSpace: enable/disable
|
||||
* DELETE /:id/webhooks/:webhookId — canManageSpace: delete
|
||||
* POST /:id/webhooks/:webhookId/test — canManageSpace: send a test notification
|
||||
*
|
||||
* SECURITY: the response DTO (toPublicWebhook) NEVER includes `url` or
|
||||
* `url_enc` — the webhook URL is write-only. Once saved it cannot be
|
||||
* re-displayed; changing it means re-entering the full URL (PUT).
|
||||
*/
|
||||
|
||||
const KNOWN_PROVIDERS: WebhookProvider[] = ['discord', 'slack', 'teams'];
|
||||
// PR1 implemented Discord delivery, PR2 added Slack, PR3 adds Teams — every
|
||||
// WebhookProvider value now has a working adapter (see webhook-service.ts's
|
||||
// ADAPTERS map). IMPLEMENTED_PROVIDERS is kept as a separate list (rather
|
||||
// than collapsed into KNOWN_PROVIDERS) so a future provider can be added to
|
||||
// the schema/type first and rejected here until its adapter ships.
|
||||
const IMPLEMENTED_PROVIDERS: WebhookProvider[] = ['discord', 'slack', 'teams'];
|
||||
const KNOWN_EVENTS: WebhookEventType[] = ['succeeded', 'failed', 'waiting_human'];
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
const MAX_LABEL_LENGTH = 200;
|
||||
|
||||
function toPublicWebhook(w: SpaceWebhookRecord) {
|
||||
return {
|
||||
id: w.id,
|
||||
provider: w.provider,
|
||||
label: w.label,
|
||||
events: w.events,
|
||||
includeDetails: w.includeDetails,
|
||||
enabled: w.enabled,
|
||||
disabledReason: w.disabledReason,
|
||||
lastSuccessAt: w.lastSuccessAt,
|
||||
lastFailureAt: w.lastFailureAt,
|
||||
failureCount: w.failureCount,
|
||||
createdAt: w.createdAt,
|
||||
updatedAt: w.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function validateEvents(input: unknown): WebhookEventType[] | { error: string } {
|
||||
if (!Array.isArray(input) || input.length === 0) {
|
||||
return { error: 'events must be a non-empty array' };
|
||||
}
|
||||
for (const e of input) {
|
||||
if (typeof e !== 'string' || !KNOWN_EVENTS.includes(e as WebhookEventType)) {
|
||||
return { error: `unknown event: ${String(e)}` };
|
||||
}
|
||||
}
|
||||
return input as WebhookEventType[];
|
||||
}
|
||||
|
||||
function validateUrl(input: unknown): string | { error: string } {
|
||||
if (typeof input !== 'string' || !input.startsWith('https://')) {
|
||||
return { error: 'url must be an https:// URL' };
|
||||
}
|
||||
if (input.length > MAX_URL_LENGTH) {
|
||||
return { error: 'url too long' };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function registerSpaceWebhooksRoutes(router: Router, deps: SpaceApiDeps): void {
|
||||
const { repo } = deps;
|
||||
const jsonParser = express.json({ limit: '64kb' });
|
||||
|
||||
async function loadSpaceAndCheckManage(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<{ viewer: Express.User; space: NonNullable<Awaited<ReturnType<typeof repo.getSpace>>> } | null> {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
const space = await repo.getSpace(req.params.id, { viewer });
|
||||
if (!space) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return null;
|
||||
}
|
||||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||||
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return null;
|
||||
}
|
||||
return { viewer, space };
|
||||
}
|
||||
|
||||
// GET — any space viewer.
|
||||
router.get('/:id/webhooks', async (req, res) => {
|
||||
const viewer = viewerOf(req, deps.authActive);
|
||||
const space = await repo.getSpace(req.params.id, { viewer });
|
||||
if (!space) return res.status(404).json({ error: 'not found' });
|
||||
try {
|
||||
const webhooks = repo.listSpaceWebhooks(space.id);
|
||||
res.json({ webhooks: webhooks.map(toPublicWebhook) });
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] GET failed space=${space.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST — canManageSpace.
|
||||
router.post('/:id/webhooks', jsonParser, async (req, res) => {
|
||||
const ctx = await loadSpaceAndCheckManage(req, res);
|
||||
if (!ctx) return;
|
||||
const { viewer, space } = ctx;
|
||||
|
||||
const provider = req.body?.provider;
|
||||
if (typeof provider !== 'string' || !KNOWN_PROVIDERS.includes(provider as WebhookProvider)) {
|
||||
return res.status(400).json({ error: `provider must be one of: ${KNOWN_PROVIDERS.join(', ')}` });
|
||||
}
|
||||
if (!IMPLEMENTED_PROVIDERS.includes(provider as WebhookProvider)) {
|
||||
return res.status(400).json({ error: `provider not yet supported: ${provider}` });
|
||||
}
|
||||
const label = String(req.body?.label ?? '').trim();
|
||||
if (!label || label.length > MAX_LABEL_LENGTH) {
|
||||
return res.status(400).json({ error: `label must be 1..${MAX_LABEL_LENGTH} chars` });
|
||||
}
|
||||
const urlResult = validateUrl(req.body?.url);
|
||||
if (typeof urlResult !== 'string') return res.status(400).json(urlResult);
|
||||
const eventsResult = validateEvents(req.body?.events);
|
||||
if (!Array.isArray(eventsResult)) return res.status(400).json(eventsResult);
|
||||
const includeDetails = req.body?.includeDetails !== false;
|
||||
|
||||
let urlEnc: Buffer;
|
||||
try {
|
||||
urlEnc = encrypt(urlResult, loadKeyFromEnv());
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] encryption unavailable: ${(err as Error).message}`);
|
||||
return res.status(503).json({ error: 'webhook encryption is not configured on this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = repo.createSpaceWebhook({
|
||||
spaceId: space.id,
|
||||
provider: provider as WebhookProvider,
|
||||
label,
|
||||
urlEnc,
|
||||
events: eventsResult,
|
||||
includeDetails,
|
||||
createdBy: viewer.id,
|
||||
});
|
||||
logger.info(`[space-webhooks-api] created webhook=${created.id} space=${space.id} provider=${provider}`);
|
||||
res.status(201).json(toPublicWebhook(created));
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] POST failed space=${space.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Shared lookup for :webhookId routes — 404s if the webhook doesn't belong to this space.
|
||||
function findWebhookInSpace(spaceId: string, webhookId: string): SpaceWebhookRecord | null {
|
||||
const webhook = repo.getSpaceWebhookById(webhookId);
|
||||
if (!webhook || webhook.spaceId !== spaceId) return null;
|
||||
return webhook;
|
||||
}
|
||||
|
||||
// PUT — canManageSpace. url is optional (write-only re-entry); omit to keep the existing URL.
|
||||
router.put('/:id/webhooks/:webhookId', jsonParser, async (req, res) => {
|
||||
const ctx = await loadSpaceAndCheckManage(req, res);
|
||||
if (!ctx) return;
|
||||
const { space } = ctx;
|
||||
const webhook = findWebhookInSpace(space.id, req.params.webhookId);
|
||||
if (!webhook) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const patch: Parameters<typeof repo.updateSpaceWebhook>[1] = {};
|
||||
if (req.body?.label !== undefined) {
|
||||
const label = String(req.body.label).trim();
|
||||
if (!label || label.length > MAX_LABEL_LENGTH) {
|
||||
return res.status(400).json({ error: `label must be 1..${MAX_LABEL_LENGTH} chars` });
|
||||
}
|
||||
patch.label = label;
|
||||
}
|
||||
if (req.body?.url !== undefined) {
|
||||
const urlResult = validateUrl(req.body.url);
|
||||
if (typeof urlResult !== 'string') return res.status(400).json(urlResult);
|
||||
try {
|
||||
patch.urlEnc = encrypt(urlResult, loadKeyFromEnv());
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] encryption unavailable: ${(err as Error).message}`);
|
||||
return res.status(503).json({ error: 'webhook encryption is not configured on this server' });
|
||||
}
|
||||
}
|
||||
if (req.body?.events !== undefined) {
|
||||
const eventsResult = validateEvents(req.body.events);
|
||||
if (!Array.isArray(eventsResult)) return res.status(400).json(eventsResult);
|
||||
patch.events = eventsResult;
|
||||
}
|
||||
if (req.body?.includeDetails !== undefined) {
|
||||
if (typeof req.body.includeDetails !== 'boolean') {
|
||||
return res.status(400).json({ error: 'includeDetails must be boolean' });
|
||||
}
|
||||
patch.includeDetails = req.body.includeDetails;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = repo.updateSpaceWebhook(webhook.id, patch);
|
||||
if (!updated) return res.status(404).json({ error: 'not found' });
|
||||
res.json(toPublicWebhook(updated));
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] PUT failed webhook=${webhook.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH — canManageSpace. Enable/disable (also used to clear an auto-disable).
|
||||
router.patch('/:id/webhooks/:webhookId', jsonParser, async (req, res) => {
|
||||
const ctx = await loadSpaceAndCheckManage(req, res);
|
||||
if (!ctx) return;
|
||||
const { space } = ctx;
|
||||
const webhook = findWebhookInSpace(space.id, req.params.webhookId);
|
||||
if (!webhook) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
if (typeof req.body?.enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be boolean' });
|
||||
}
|
||||
try {
|
||||
const updated = repo.setSpaceWebhookEnabled(webhook.id, req.body.enabled);
|
||||
if (!updated) return res.status(404).json({ error: 'not found' });
|
||||
logger.info(`[space-webhooks-api] webhook=${webhook.id} enabled=${req.body.enabled}`);
|
||||
res.json(toPublicWebhook(updated));
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] PATCH failed webhook=${webhook.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE — canManageSpace.
|
||||
router.delete('/:id/webhooks/:webhookId', async (req, res) => {
|
||||
const ctx = await loadSpaceAndCheckManage(req, res);
|
||||
if (!ctx) return;
|
||||
const { space } = ctx;
|
||||
const webhook = findWebhookInSpace(space.id, req.params.webhookId);
|
||||
if (!webhook) return res.status(404).json({ error: 'not found' });
|
||||
try {
|
||||
repo.deleteSpaceWebhook(webhook.id);
|
||||
logger.info(`[space-webhooks-api] deleted webhook=${webhook.id} space=${space.id}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] DELETE failed webhook=${webhook.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:webhookId/test — canManageSpace. Sends one real notification synchronously.
|
||||
router.post('/:id/webhooks/:webhookId/test', async (req, res) => {
|
||||
const ctx = await loadSpaceAndCheckManage(req, res);
|
||||
if (!ctx) return;
|
||||
const { space } = ctx;
|
||||
const webhook = findWebhookInSpace(space.id, req.params.webhookId);
|
||||
if (!webhook) return res.status(404).json({ error: 'not found' });
|
||||
if (!deps.webhookService) {
|
||||
return res.status(503).json({ error: 'webhook delivery is disabled (config: webhooks.enabled)' });
|
||||
}
|
||||
try {
|
||||
const result = await deps.webhookService.sendTest(webhook.id);
|
||||
if (result.ok) return res.json({ ok: true });
|
||||
return res.status(502).json({ ok: false, error: result.error });
|
||||
} catch (err) {
|
||||
logger.error(`[space-webhooks-api] test-send failed webhook=${webhook.id} err=${(err as Error).message}`);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -38,6 +38,7 @@ import { listAgentConsoleSessions, closeConsoleSessionByUser } from '../engine/t
|
||||
import { __setActiveSessionLookup } from '../engine/agent-loop.js';
|
||||
import {
|
||||
createConsoleStatusRouter,
|
||||
createConsoleStatusStubRouter,
|
||||
createConsoleSessionRouter,
|
||||
createConsoleSessionsRouter,
|
||||
createConsoleSessionCloseRouter,
|
||||
@ -91,13 +92,26 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
|
||||
let sshConsole: SshConsoleDeps | null = null;
|
||||
|
||||
const sshConfig = mergeSshConfig(loadConfig().ssh);
|
||||
if (!sshConfig.enabled) {
|
||||
const sshAvailable = sshConfig.enabled && isKeyConfigured();
|
||||
if (!sshAvailable) {
|
||||
setSshSubsystem(null);
|
||||
__setActiveSessionLookup(null);
|
||||
} else if (!isKeyConfigured()) {
|
||||
// SSH is off: the real console/status router (and the registry /
|
||||
// resolveTask / resolveSshAccess it needs) never gets mounted below,
|
||||
// but the UI still polls GET .../console/status every 5s regardless of
|
||||
// SSH config. Without a stand-in, that poll 404s forever and spams the
|
||||
// DevTools console (issues #785, #813). Mount a minimal stub that always
|
||||
// answers `{ active: false }`.
|
||||
if (sshConfig.enabled) {
|
||||
logger.warn('[ssh] MCP_ENCRYPTION_KEY not configured — SSH features disabled');
|
||||
setSshSubsystem(null);
|
||||
__setActiveSessionLookup(null);
|
||||
}
|
||||
app.use(
|
||||
'/api',
|
||||
createConsoleStatusStubRouter({
|
||||
authActive,
|
||||
requireAuth: authActive ? requireAuth : (_req: Request, _res: Response, next: NextFunction) => next(),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
bootstrapSystemDek(repo.getDb());
|
||||
@ -282,6 +296,7 @@ export function setupSshSubsystem(app: express.Application, deps: SshSubsystemDe
|
||||
orgIds: resolveOrgIds(repo, user.id),
|
||||
defaultVisibility: 'private',
|
||||
defaultVisibilityOrgId: null,
|
||||
hasLocalCredential: repo.hasLocalCredential(user.id),
|
||||
};
|
||||
const task = await repo.getLocalTask(idNum, { viewer });
|
||||
if (!task) return null;
|
||||
|
||||
101
src/config-manager.extra-body.test.ts
Normal file
101
src/config-manager.extra-body.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
// src/config-manager.extra-body.test.ts
|
||||
//
|
||||
// extraBody の「部分更新でキー削除が反映される」保証を検証する。
|
||||
//
|
||||
// 到達経路の実態:
|
||||
// - extraBody は現状 llm.workers[] / provider.workers[] の配列内にのみ存在する
|
||||
// - deepMergeConfig は配列を丸ごと置換する(再帰しない)ため、workers 配列の
|
||||
// PUT では extraBody の旧キー残留はそもそも起きない(Test A がこれを検証)
|
||||
// - deepMergeConfig 内の `key === 'extraBody'` ガードは、将来 per-worker の
|
||||
// partial merge 経路(配列でなくオブジェクトとして worker を merge する経路)
|
||||
// が追加された場合の防御。Test B が未知セクション経由の再帰でこのガード自体を
|
||||
// 検証する(ガードを外すと Test B は失敗する = 実効性のある回帰テスト)
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
|
||||
describe('ConfigManager extraBody atomic replacement', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'config-manager-extra-body-'));
|
||||
});
|
||||
|
||||
it('workers 配列更新で extraBody の旧キーが残らない(配列丸ごと置換)', () => {
|
||||
// 実際の UI/API 経路: llm.workers はまるごと配列で PUT される。
|
||||
// deepMergeConfig は配列を再帰せず丸ごと置換するので、
|
||||
// extraBody から削除したキー(top_k)は結果に残らない。
|
||||
const configPath = join(tempDir, 'config.yaml');
|
||||
writeFileSync(configPath, [
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: gpu1',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://gpu1.example/v1',
|
||||
' model: test-model',
|
||||
' extra_body:',
|
||||
' reasoning_effort: max',
|
||||
' top_k: 20',
|
||||
].join('\n'));
|
||||
|
||||
const cm = new ConfigManager(configPath);
|
||||
expect((cm.getConfig() as any).llm.workers[0].extraBody)
|
||||
.toEqual({ reasoning_effort: 'max', top_k: 20 });
|
||||
|
||||
const result = cm.updateConfig({
|
||||
llm: {
|
||||
workers: [
|
||||
{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://gpu1.example/v1',
|
||||
model: 'test-model',
|
||||
extraBody: { reasoning_effort: 'max' }, // top_k を削除した状態で PUT
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const updated = (cm.getConfig() as any).llm.workers[0];
|
||||
expect(updated.extraBody).toEqual({ reasoning_effort: 'max' });
|
||||
expect(updated.extraBody).not.toHaveProperty('top_k');
|
||||
});
|
||||
|
||||
it('deepMergeConfig が再帰到達する経路でも extraBody は丸ごと置換される(ガード検証)', () => {
|
||||
// deepMergeConfig の extraBody ガード自体の検証。
|
||||
// updateConfig は未知のトップレベルセクションも素通しで merge → YAML 書き出し
|
||||
// →再読込するため、プレーンオブジェクトのセクション内に extraBody を置くと
|
||||
// deepMergeConfig の再帰がそこに到達する。ガードが無いと再帰マージで
|
||||
// 旧キー b が残留し、このテストは失敗する。
|
||||
const configPath = join(tempDir, 'config.yaml');
|
||||
writeFileSync(configPath, [
|
||||
'llm:',
|
||||
' workers:',
|
||||
' - id: gpu1',
|
||||
' connection_type: direct',
|
||||
' endpoint: http://gpu1.example/v1',
|
||||
' model: test-model',
|
||||
'synthetic_section:',
|
||||
' extra_body:',
|
||||
' a: 1',
|
||||
' b: 2',
|
||||
].join('\n'));
|
||||
|
||||
const cm = new ConfigManager(configPath);
|
||||
expect((cm.getConfig() as any).syntheticSection.extraBody).toEqual({ a: 1, b: 2 });
|
||||
|
||||
const result = cm.updateConfig({
|
||||
syntheticSection: {
|
||||
extraBody: { a: 1 }, // b を削除した partial update
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const updated = (cm.getConfig() as any).syntheticSection.extraBody;
|
||||
expect(updated).toEqual({ a: 1 });
|
||||
expect(updated).not.toHaveProperty('b');
|
||||
});
|
||||
});
|
||||
@ -308,7 +308,13 @@ function deepMergeConfig(base: any, override: any): any {
|
||||
if (typeof override !== 'object' || Array.isArray(override)) return override;
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value) && typeof result[key] === 'object') {
|
||||
// extraBody は不透明 JSON なので常に丸ごと置換(キー削除を可能にする)。
|
||||
// 現状 extraBody は workers 配列内にのみ存在し、配列は上の分岐で丸ごと
|
||||
// 置換されるためこの分岐は防御的(将来 per-worker partial merge 経路が
|
||||
// 追加された場合の取りこぼし防止)。
|
||||
if (key === 'extraBody') {
|
||||
result[key] = value;
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value) && typeof result[key] === 'object') {
|
||||
result[key] = deepMergeConfig(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
|
||||
296
src/config-normalize.extra-body.test.ts
Normal file
296
src/config-normalize.extra-body.test.ts
Normal file
@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Tests for extraBody / reasoningEfforts / reasoningEffortMode propagation
|
||||
* through every config-normalize.ts path (Task 2 of the LLM extra-body /
|
||||
* reasoning-effort passthrough feature).
|
||||
*
|
||||
* Coverage matrix:
|
||||
* - normalizeLlmWorker: extraBody validated (drop non-plain-object),
|
||||
* reasoningEfforts filtered/deduped (drop empty result),
|
||||
* reasoningEffortMode coerced to one of the two allowed literals or dropped
|
||||
* - syncProviderFromLlm mirroredWorkers branch: 3 fields carried into
|
||||
* provider.workers when provider.workers didn't already match by id
|
||||
* - syncProviderFromLlm early-return branch: 3 fields synced per-field onto
|
||||
* pre-existing matching provider.workers rows (mirrors returnProgress
|
||||
* handling added for Codex P2)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeConfig } from './config-normalize.js';
|
||||
|
||||
describe('normalizeLlmWorker — extraBody/reasoningEfforts/reasoningEffortMode validation', () => {
|
||||
it('keeps a plain-object extraBody', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
extraBody: { reasoning_effort: 'high' },
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({ extraBody: { reasoning_effort: 'high' } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['string', 'nope'],
|
||||
['array', ['a', 'b']],
|
||||
['null', null],
|
||||
])('drops extraBody when it is a %s, not a plain object', (_label, badValue) => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
extraBody: badValue,
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('extraBody');
|
||||
});
|
||||
|
||||
it('filters non-string/blank entries and dedupes reasoningEfforts', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
reasoningEfforts: ['high', 'high', '', ' ', 42, 'low'],
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({ reasoningEfforts: ['high', 'low'] });
|
||||
});
|
||||
|
||||
it('drops reasoningEfforts entirely when filtering leaves nothing', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
reasoningEfforts: ['', ' ', 42],
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('reasoningEfforts');
|
||||
});
|
||||
|
||||
it.each(['body', 'chat_template_kwargs'] as const)(
|
||||
'accepts reasoningEffortMode %s',
|
||||
(mode) => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
reasoningEffortMode: mode,
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({ reasoningEffortMode: mode });
|
||||
},
|
||||
);
|
||||
|
||||
it('drops reasoningEffortMode when it is not one of the two allowed literals', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
reasoningEffortMode: 'bogus',
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('reasoningEffortMode');
|
||||
});
|
||||
|
||||
it('omitted fields stay absent', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{ id: 'gpu1', connectionType: 'direct', endpoint: 'http://x:8080/v1', model: 'm' }],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('extraBody');
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('reasoningEfforts');
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('reasoningEffortMode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extraBody/reasoningEfforts/reasoningEffortMode mirroring into provider.workers', () => {
|
||||
it('v2 llm.workers fields survive normalization and mirror into provider.workers (mirroredWorkers branch)', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high', 'low'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high', 'low'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
});
|
||||
expect(out.provider?.workers?.[0]).toMatchObject({
|
||||
id: 'gpu1',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high', 'low'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
});
|
||||
});
|
||||
|
||||
it('v1 provider.workers fields carry into the generated llm block (workerFromProvider)', () => {
|
||||
const out = normalizeConfig({
|
||||
provider: {
|
||||
baseUrl: 'http://x:11434/v1',
|
||||
model: 'm',
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'body',
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({
|
||||
id: 'gpu1',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'body',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 provider.workers[] legacy path validates passthrough fields (Codex P2)', () => {
|
||||
it.each([
|
||||
['string', 'oops'],
|
||||
['array', [1, 2]],
|
||||
])('drops extraBody when the legacy provider.workers[] value is a %s', (_label, badValue) => {
|
||||
const out = normalizeConfig({
|
||||
provider: {
|
||||
baseUrl: 'http://x:11434/v1',
|
||||
model: 'm',
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
extraBody: badValue,
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('extraBody');
|
||||
expect(out.provider?.workers?.[0]).not.toHaveProperty('extraBody');
|
||||
});
|
||||
|
||||
it('filters blank entries and dedupes reasoningEfforts on the legacy provider.workers[] path, matching v2 behavior', () => {
|
||||
// Matches normalizeLlmWorker's existing semantics exactly: entries are
|
||||
// kept when `trim().length > 0` but the *stored* value is NOT itself
|
||||
// trimmed (e.g. ' low ' survives filtering with its surrounding spaces
|
||||
// intact) — see the identical assertion shape in the v2 test above
|
||||
// ('filters non-string/blank entries and dedupes reasoningEfforts').
|
||||
const out = normalizeConfig({
|
||||
provider: {
|
||||
baseUrl: 'http://x:11434/v1',
|
||||
model: 'm',
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
reasoningEfforts: ['high', '', 'high', ' low '],
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).toMatchObject({ reasoningEfforts: ['high', ' low '] });
|
||||
});
|
||||
|
||||
it('drops reasoningEffortMode on the legacy provider.workers[] path when not an allowed literal', () => {
|
||||
const out = normalizeConfig({
|
||||
provider: {
|
||||
baseUrl: 'http://x:11434/v1',
|
||||
model: 'm',
|
||||
workers: [{
|
||||
id: 'gpu1',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
reasoningEffortMode: 'bogus',
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.llm?.workers[0]).not.toHaveProperty('reasoningEffortMode');
|
||||
expect(out.provider?.workers?.[0]).not.toHaveProperty('reasoningEffortMode');
|
||||
});
|
||||
});
|
||||
|
||||
// syncProviderFromLlm early-return branch: loadConfig が同 id の legacy
|
||||
// provider.workers を先に合成する一般的な v2 構成では mirroredWorkers branch
|
||||
// を通らず early-return する。returnProgress (Codex P2) と同じパターンで
|
||||
// extraBody/reasoningEfforts/reasoningEffortMode も per-field 同期する必要がある。
|
||||
describe('extraBody/reasoningEfforts/reasoningEffortMode sync when provider.workers already match', () => {
|
||||
it('copies all 3 fields onto pre-existing matching provider.workers rows', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
provider: {
|
||||
workers: [{ id: 'default', endpoint: 'http://x:8080/v1' }],
|
||||
},
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'default',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
model: 'm',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
}],
|
||||
},
|
||||
} as never);
|
||||
expect(out.provider?.workers?.[0]).toMatchObject({
|
||||
id: 'default',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale fields on provider.workers when the v2 side no longer sets them', () => {
|
||||
const out = normalizeConfig({
|
||||
configVersion: 2,
|
||||
provider: {
|
||||
workers: [{
|
||||
id: 'default',
|
||||
endpoint: 'http://x:8080/v1',
|
||||
extraBody: { foo: 'bar' },
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
}],
|
||||
},
|
||||
llm: {
|
||||
workers: [{ id: 'default', connectionType: 'direct', endpoint: 'http://x:8080/v1', model: 'm' }],
|
||||
},
|
||||
} as never);
|
||||
expect(out.provider?.workers?.[0]).not.toHaveProperty('extraBody');
|
||||
expect(out.provider?.workers?.[0]).not.toHaveProperty('reasoningEfforts');
|
||||
expect(out.provider?.workers?.[0]).not.toHaveProperty('reasoningEffortMode');
|
||||
});
|
||||
});
|
||||
@ -169,6 +169,17 @@ function resolveConfigVersion(raw: unknown): 1 | 2 {
|
||||
* `tools.task_upload_max_size_mb` / `tools.trash_retention_days`
|
||||
* are mirrored into `storage.*`
|
||||
* - legacy keys are PRESERVED so downstream readers keep working.
|
||||
*
|
||||
* Also syncs the validated extraBody/reasoningEfforts/reasoningEffortMode
|
||||
* back onto `out.provider.workers` (see `syncProviderFromLlm` call below):
|
||||
* `config.provider.workers` — not `config.llm.workers` — is what the
|
||||
* runtime actually reads to build LLM clients (worker-manager.ts,
|
||||
* worker.ts, worker-bootstrap.ts all consume `config.provider.workers`
|
||||
* during this compat window, and `normalizeWorkerDefs` in config.ts spreads
|
||||
* worker fields through unvalidated). Without this sync, a malformed value
|
||||
* on a pure v1 config (`configVersion` unset) would be dropped from the
|
||||
* `llm.*` mirror but survive untouched on `provider.workers` — the exact
|
||||
* field the request body is actually built from (Codex P2).
|
||||
*/
|
||||
function migrateV1InPlace(out: Record<string, unknown>): void {
|
||||
const provider = (out.provider ?? {}) as ProviderConfig;
|
||||
@ -179,6 +190,8 @@ function migrateV1InPlace(out: Record<string, unknown>): void {
|
||||
: llmFromProvider(provider);
|
||||
out.llm = llm;
|
||||
|
||||
syncProviderFromLlm(out, llm);
|
||||
|
||||
out.storage = buildStorage(out);
|
||||
}
|
||||
|
||||
@ -271,6 +284,15 @@ function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void
|
||||
if (!src) continue;
|
||||
if (src.returnProgress !== undefined) existing.returnProgress = src.returnProgress;
|
||||
else delete existing.returnProgress;
|
||||
// extraBody/reasoningEfforts/reasoningEffortMode: clear stale values
|
||||
// first (matches the delete-when-absent semantics above), then
|
||||
// re-apply via the shared validator so a malformed `src` (defensive —
|
||||
// `src` should already be normalized upstream, but this keeps the
|
||||
// contract regardless) can't resurrect a bad value on `existing`.
|
||||
delete existing.extraBody;
|
||||
delete existing.reasoningEfforts;
|
||||
delete existing.reasoningEffortMode;
|
||||
applyPassthroughFields(existing, src as unknown as Record<string, unknown>);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -294,6 +316,7 @@ function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void
|
||||
def.proxy = true;
|
||||
def.proxyType = 'litellm';
|
||||
}
|
||||
applyPassthroughFields(def, w as unknown as Record<string, unknown>);
|
||||
return def;
|
||||
});
|
||||
|
||||
@ -393,6 +416,7 @@ function workerFromProvider(w: WorkerDef, providerModel: string | undefined): Ll
|
||||
if (w.healthcheckIntervalSeconds !== undefined) {
|
||||
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
|
||||
}
|
||||
applyPassthroughFields(worker, w as unknown as Record<string, unknown>);
|
||||
return worker;
|
||||
}
|
||||
|
||||
@ -441,9 +465,53 @@ function normalizeLlmWorker(w: Partial<LlmWorkerDef> & Record<string, unknown>):
|
||||
if (typeof w.healthcheckIntervalSeconds === 'number') {
|
||||
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
|
||||
}
|
||||
applyPassthroughFields(worker, w as Record<string, unknown>);
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + copy the passthrough LLM fields (`extraBody` / `reasoningEfforts`
|
||||
* / `reasoningEffortMode`) from a raw source object onto a target worker.
|
||||
*
|
||||
* These three fields skip normal type-checking further downstream:
|
||||
* `OpenAICompatClient.sanitizeExtraBody` only strips reserved *keys* via
|
||||
* `Object.entries(extra)` — it does not guard against `extra` itself being a
|
||||
* non-object (e.g. `Object.entries("oops")` yields `[['0','o'], ...]`, which
|
||||
* gets spread into the wire request body as garbage fields). So a malformed
|
||||
* value must never survive normalization, on ANY code path that produces a
|
||||
* worker (v1 `provider.workers[]`, v2 `llm.workers[]`, or the mirrors between
|
||||
* them) — not just the hand-written v2 `normalizeLlmWorker` path.
|
||||
*
|
||||
* Malformed values are dropped (left unset on `target`), never copied as-is.
|
||||
* Sets the field on `target` when `src` has a valid value; otherwise leaves
|
||||
* `target`'s existing value for that field untouched (callers that need
|
||||
* "clear stale field when absent" semantics, e.g. the early-return
|
||||
* per-field sync in `syncProviderFromLlm`, must delete first — see there).
|
||||
*/
|
||||
function applyPassthroughFields(
|
||||
target: {
|
||||
extraBody?: Record<string, unknown>;
|
||||
reasoningEfforts?: string[];
|
||||
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
|
||||
},
|
||||
src: Record<string, unknown>,
|
||||
): void {
|
||||
if (src.extraBody !== null && typeof src.extraBody === 'object' && !Array.isArray(src.extraBody)) {
|
||||
target.extraBody = src.extraBody as Record<string, unknown>;
|
||||
}
|
||||
if (Array.isArray(src.reasoningEfforts)) {
|
||||
const efforts = Array.from(new Set(
|
||||
(src.reasoningEfforts as unknown[]).filter(
|
||||
(e): e is string => typeof e === 'string' && e.trim().length > 0,
|
||||
),
|
||||
));
|
||||
if (efforts.length > 0) target.reasoningEfforts = efforts;
|
||||
}
|
||||
if (src.reasoningEffortMode === 'chat_template_kwargs' || src.reasoningEffortMode === 'body') {
|
||||
target.reasoningEffortMode = src.reasoningEffortMode;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultWorker(endpoint: string, model: string): LlmWorkerDef {
|
||||
if (model === EMPTY_MODEL) {
|
||||
logger.warn(
|
||||
|
||||
24
src/config.extra-body.test.ts
Normal file
24
src/config.extra-body.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { transformKeys, toSnakeKeys } from './config.js';
|
||||
|
||||
describe('transformKeys extra_body passthrough', () => {
|
||||
it('extra_body の中身のキーは camelCase 変換しない', () => {
|
||||
const input = {
|
||||
llm: { workers: [{ id: 'w1', extra_body: { reasoning_effort: 'max', chat_template_kwargs: { enable_thinking: true } } }] },
|
||||
};
|
||||
const out = transformKeys(input) as any;
|
||||
expect(out.llm.workers[0].extraBody).toEqual({
|
||||
reasoning_effort: 'max',
|
||||
chat_template_kwargs: { enable_thinking: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('toSnakeKeys は extraBody の中身を snake_case 変換しない(往復不変)', () => {
|
||||
const camel = { llm: { workers: [{ id: 'w1', extraBody: { reasoning_effort: 'max', someCamelKey: 1 } }] } };
|
||||
const snake = toSnakeKeys(camel) as any;
|
||||
expect(snake.llm.workers[0].extra_body).toEqual({ reasoning_effort: 'max', someCamelKey: 1 });
|
||||
// 往復して壊れないこと
|
||||
const back = transformKeys(snake) as any;
|
||||
expect(back.llm.workers[0].extraBody).toEqual(camel.llm.workers[0].extraBody);
|
||||
});
|
||||
});
|
||||
@ -171,6 +171,14 @@ export interface WorkerDef {
|
||||
* without an Authorization header.
|
||||
*/
|
||||
apiKey?: string;
|
||||
/** OpenAI 互換 request body へ浅いマージする任意 JSON(例: {reasoning_effort: 'max'})。
|
||||
* 中身のキーは変換されずそのまま wire に載る。秘密値を入れないこと(config API でマスクされない)。 */
|
||||
extraBody?: Record<string, unknown>;
|
||||
/** このワーカーが受け付ける reasoning effort の宣言リスト(自由文字列、例 ['low','medium','high','max'])。 */
|
||||
reasoningEfforts?: string[];
|
||||
/** ジョブ単位 effort の注入先。body=トップレベル reasoning_effort(vLLM)、
|
||||
* chat_template_kwargs=chat_template_kwargs.reasoning_effort(llama-server)。既定 body。 */
|
||||
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
@ -453,6 +461,14 @@ export interface LlmWorkerDef {
|
||||
/** llama.cpp の prompt 評価進捗ストリーム(return_progress)を要求する。既定 off。 */
|
||||
returnProgress?: boolean;
|
||||
healthcheckIntervalSeconds?: number;
|
||||
/** OpenAI 互換 request body へ浅いマージする任意 JSON(例: {reasoning_effort: 'max'})。
|
||||
* 中身のキーは変換されずそのまま wire に載る。秘密値を入れないこと(config API でマスクされない)。 */
|
||||
extraBody?: Record<string, unknown>;
|
||||
/** このワーカーが受け付ける reasoning effort の宣言リスト(自由文字列、例 ['low','medium','high','max'])。 */
|
||||
reasoningEfforts?: string[];
|
||||
/** ジョブ単位 effort の注入先。body=トップレベル reasoning_effort(vLLM)、
|
||||
* chat_template_kwargs=chat_template_kwargs.reasoning_effort(llama-server)。既定 body。 */
|
||||
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -501,6 +517,50 @@ export interface NotificationsConfig {
|
||||
push?: PushNotificationsConfig;
|
||||
}
|
||||
|
||||
export interface WebhooksConfig {
|
||||
/** Master switch for workspace webhook notifications (Discord/Slack/Teams). Default false. */
|
||||
enabled?: boolean;
|
||||
/** Per-send HTTP timeout (ms). */
|
||||
timeoutMs?: number;
|
||||
/** Maximum outbound payload size in bytes. */
|
||||
payloadMaxBytes?: number;
|
||||
/** Max redirect hops ssrfSafeFetch will follow per send. */
|
||||
maxRedirects?: number;
|
||||
/** Max concurrent sends from the queue. */
|
||||
queueConcurrency?: number;
|
||||
retry?: {
|
||||
/** Number of retries after the initial attempt for 429/5xx/timeout. */
|
||||
maxAttempts?: number;
|
||||
/** Base backoff (ms); doubled per attempt with jitter. */
|
||||
backoffMs?: number;
|
||||
};
|
||||
/** Optional absolute base URL used to build task-detail links in payloads
|
||||
* (e.g. "https://maestro.example.com"). Falls back to a relative path
|
||||
* when unset. */
|
||||
publicBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* External chat connector (issue #801, PR1 — Slack MVP). Master switch is
|
||||
* `chat.enabled` (default false). Reuses the existing A2A execution/governance
|
||||
* path in-process; there is no separate authz surface. snake_case in YAML:
|
||||
* chat.{enabled,public_base_url,slack.signing_secret_max_age_sec}
|
||||
*/
|
||||
export interface ChatConfig {
|
||||
/** Master switch for the chat connector subsystem. Default false. */
|
||||
enabled?: boolean;
|
||||
/** Absolute base URL used to build task-detail links in reply messages
|
||||
* (e.g. "https://maestro.example.com"). Falls back to a relative path
|
||||
* when unset. */
|
||||
publicBaseUrl?: string;
|
||||
slack?: {
|
||||
/** Reject Slack Events API requests whose X-Slack-Request-Timestamp is
|
||||
* older than this many seconds (replay protection). Default 300 (5 min),
|
||||
* matching Slack's own recommendation. */
|
||||
signingSecretMaxAgeSec?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
/**
|
||||
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
|
||||
@ -554,6 +614,15 @@ export interface AppConfig {
|
||||
mcp?: Partial<McpRuntimeConfig>;
|
||||
ssh?: Partial<SshRuntimeConfig>;
|
||||
notifications?: NotificationsConfig;
|
||||
/**
|
||||
* Workspace-level webhook notifications (Discord/Slack/Teams). Spec: issue
|
||||
* #797. Disabled by default; when `webhooks.enabled` is false the service
|
||||
* does not start / no-ops sends (mirrors notifications.push.enabled gating
|
||||
* in worker-bootstrap.ts). snake_case in YAML:
|
||||
* webhooks.{enabled,timeout_ms,payload_max_bytes,max_redirects,queue_concurrency,
|
||||
* retry.{max_attempts,backoff_ms},public_base_url}
|
||||
*/
|
||||
webhooks?: WebhooksConfig;
|
||||
server?: Partial<ServerConfig>;
|
||||
a2a?: {
|
||||
enabled?: boolean;
|
||||
@ -568,6 +637,7 @@ export interface AppConfig {
|
||||
maxConcurrentResubscribe?: number;
|
||||
};
|
||||
};
|
||||
chat?: ChatConfig;
|
||||
}
|
||||
|
||||
const DEFAULT_REFLECTION: ReflectionConfig = {
|
||||
@ -641,13 +711,17 @@ function toCamel(s: string): string {
|
||||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** これらのキーの「値」はプロバイダーへ素通しする JSON なのでキー変換しない
|
||||
* (migrate-config.ts の複製変換からも参照される) */
|
||||
export const OPAQUE_CONFIG_KEYS = new Set(['extra_body', 'extraBody']);
|
||||
|
||||
export function transformKeys(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(transformKeys);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
||||
toCamel(k),
|
||||
transformKeys(v),
|
||||
OPAQUE_CONFIG_KEYS.has(k) ? v : transformKeys(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
@ -664,7 +738,7 @@ export function toSnakeKeys(obj: unknown): unknown {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
||||
toSnake(k),
|
||||
toSnakeKeys(v),
|
||||
OPAQUE_CONFIG_KEYS.has(k) ? v : toSnakeKeys(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
30
src/db/migrate.chat-connector-events.test.ts
Normal file
30
src/db/migrate.chat-connector-events.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from './migrate.js';
|
||||
import { claimChatConnectorEvent } from './repositories/chat-connectors.js';
|
||||
|
||||
describe('chat connector processed-event migration', () => {
|
||||
it('adds an idempotent event claim table to an existing bindings database', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE chat_connector_bindings (
|
||||
id TEXT PRIMARY KEY, platform TEXT NOT NULL, external_workspace_id TEXT NOT NULL,
|
||||
external_channel_id TEXT NOT NULL, space_id TEXT NOT NULL, a2a_client_id TEXT NOT NULL,
|
||||
a2a_delegation_id TEXT NOT NULL, a2a_grant_id TEXT NOT NULL,
|
||||
bot_credentials_enc BLOB NOT NULL, key_version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active', created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_connector_processed_events'").get()).toBeTruthy();
|
||||
expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(true);
|
||||
expect(claimChatConnectorEvent(db, 'Ev-upgrade-1')).toBe(false);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
221
src/db/migrate.llm-selection.test.ts
Normal file
221
src/db/migrate.llm-selection.test.ts
Normal file
@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { Repository } from './repository.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
describe('LLM selection Phase 1: jobs / local_tasks columns', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'llm-selection-cols-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('jobs table has required_worker_id and reasoning_effort columns (nullable)', () => {
|
||||
const cols = r.getDb()
|
||||
.prepare("PRAGMA table_info(jobs)")
|
||||
.all() as Array<{ name: string; notnull: number }>;
|
||||
const requiredWorkerId = cols.find(c => c.name === 'required_worker_id');
|
||||
const reasoningEffort = cols.find(c => c.name === 'reasoning_effort');
|
||||
expect(requiredWorkerId).toBeTruthy();
|
||||
expect(requiredWorkerId?.notnull).toBe(0);
|
||||
expect(reasoningEffort).toBeTruthy();
|
||||
expect(reasoningEffort?.notnull).toBe(0);
|
||||
});
|
||||
|
||||
it('local_tasks table has llm_worker_id and llm_effort columns (nullable)', () => {
|
||||
const cols = r.getDb()
|
||||
.prepare("PRAGMA table_info(local_tasks)")
|
||||
.all() as Array<{ name: string; notnull: number }>;
|
||||
const llmWorkerId = cols.find(c => c.name === 'llm_worker_id');
|
||||
const llmEffort = cols.find(c => c.name === 'llm_effort');
|
||||
expect(llmWorkerId).toBeTruthy();
|
||||
expect(llmWorkerId?.notnull).toBe(0);
|
||||
expect(llmEffort).toBeTruthy();
|
||||
expect(llmEffort?.notnull).toBe(0);
|
||||
});
|
||||
|
||||
it('runMigrations is idempotent when run twice on the same DB (ALTER does not throw)', () => {
|
||||
// Repository constructor already ran migrations once via initSchema/migrate path.
|
||||
// Running again must not throw ("duplicate column name").
|
||||
expect(() => runMigrations(r.getDb())).not.toThrow();
|
||||
expect(() => runMigrations(r.getDb())).not.toThrow();
|
||||
});
|
||||
|
||||
it('ALTER TABLE ADD COLUMN path works starting from a DB missing the 4 columns', () => {
|
||||
// Simulate an old DB: create fresh jobs/local_tasks tables WITHOUT the new
|
||||
// columns (pre-Phase-1 shape), then run migrations and assert they appear.
|
||||
const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-old-'));
|
||||
try {
|
||||
const db = new Database(join(oldDir, 'old.sqlite'));
|
||||
db.exec(`
|
||||
CREATE TABLE jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
required_profile TEXT NOT NULL DEFAULT 'auto',
|
||||
task_class TEXT NOT NULL DEFAULT 'auto',
|
||||
instruction TEXT NOT NULL DEFAULT '',
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
ask_count INTEGER NOT NULL DEFAULT 0,
|
||||
subtask_depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE local_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
profile TEXT NOT NULL DEFAULT 'auto',
|
||||
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||
ask_policy TEXT NOT NULL DEFAULT 'low',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
state TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const beforeCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||
expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false);
|
||||
|
||||
runMigrations(db);
|
||||
// Run twice to prove idempotency from the old-schema starting point too.
|
||||
runMigrations(db);
|
||||
|
||||
const afterJobsCols = db.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||
expect(afterJobsCols.some(c => c.name === 'required_worker_id')).toBe(true);
|
||||
expect(afterJobsCols.some(c => c.name === 'reasoning_effort')).toBe(true);
|
||||
|
||||
const afterTasksCols = db.prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>;
|
||||
expect(afterTasksCols.some(c => c.name === 'llm_worker_id')).toBe(true);
|
||||
expect(afterTasksCols.some(c => c.name === 'llm_effort')).toBe(true);
|
||||
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(oldDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('LLM selection Phase 1: bare `new Repository()` compat-init path (no runMigrations)', () => {
|
||||
// Repository's constructor calls initSchema() only (src/db/repository.ts:58),
|
||||
// NOT runMigrations(). initSchema is a separate compat-upgrade path
|
||||
// (src/db/repositories/schema.ts) that must independently ALTER in the 4
|
||||
// new columns, or any code path that does `new Repository(dbPath)` against
|
||||
// a pre-Phase-1 DB (tests, tools, integration paths that skip
|
||||
// runMigrations) will hit "no column required_worker_id" in createJob.
|
||||
it('adds the 4 columns and allows createJob(requiredWorkerId=...) on a pre-Phase-1 DB opened via bare Repository construction', async () => {
|
||||
const oldDir = mkdtempSync(join(tmpdir(), 'llm-selection-bare-'));
|
||||
try {
|
||||
const dbPath = join(oldDir, 'old.sqlite');
|
||||
const raw = new Database(dbPath);
|
||||
raw.exec(`
|
||||
CREATE TABLE jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
pr_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','dispatching','running','succeeded','failed','retry','cancelled','waiting_human','waiting_subtasks')),
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
required_profile TEXT NOT NULL DEFAULT 'auto',
|
||||
task_class TEXT NOT NULL DEFAULT 'auto',
|
||||
current_movement TEXT,
|
||||
instruction TEXT NOT NULL DEFAULT '',
|
||||
branch_name TEXT,
|
||||
worktree_path TEXT,
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
next_retry_at TEXT,
|
||||
error_summary TEXT,
|
||||
resume_movement TEXT,
|
||||
ask_count INTEGER NOT NULL DEFAULT 0,
|
||||
worker_id TEXT,
|
||||
parent_job_id TEXT,
|
||||
subtask_depth INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE local_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
profile TEXT NOT NULL DEFAULT 'auto',
|
||||
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||
ask_policy TEXT NOT NULL DEFAULT 'low',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
state TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
const beforeCols = raw.prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||
expect(beforeCols.some(c => c.name === 'required_worker_id')).toBe(false);
|
||||
raw.close();
|
||||
|
||||
// Bare construction — deliberately does NOT call runMigrations.
|
||||
const repo = new Repository(dbPath);
|
||||
try {
|
||||
const jobsCols = repo.getDb().prepare("PRAGMA table_info(jobs)").all() as Array<{ name: string }>;
|
||||
expect(jobsCols.some(c => c.name === 'required_worker_id')).toBe(true);
|
||||
expect(jobsCols.some(c => c.name === 'reasoning_effort')).toBe(true);
|
||||
|
||||
const taskCols = repo.getDb().prepare("PRAGMA table_info(local_tasks)").all() as Array<{ name: string }>;
|
||||
expect(taskCols.some(c => c.name === 'llm_worker_id')).toBe(true);
|
||||
expect(taskCols.some(c => c.name === 'llm_effort')).toBe(true);
|
||||
|
||||
// End-to-end: createJob with a pin must not throw a SQLite
|
||||
// "no column required_worker_id" error on this bare-init DB.
|
||||
const job = await repo.createJob({
|
||||
repo: 'local/task-1',
|
||||
issueNumber: 1,
|
||||
instruction: 'go',
|
||||
pieceName: 'general',
|
||||
requiredWorkerId: 'worker-gpu-1',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
expect(job.requiredWorkerId).toBe('worker-gpu-1');
|
||||
expect(job.reasoningEffort).toBe('high');
|
||||
} finally {
|
||||
repo.close?.();
|
||||
}
|
||||
} finally {
|
||||
rmSync(oldDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -222,6 +222,22 @@ export function runMigrations(db: Database.Database): void {
|
||||
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
|
||||
});
|
||||
|
||||
// LLM 選択 Phase 1: per-job worker/effort pin + per-task スティッキー選択。
|
||||
// NULL = 従来の profile ルーティング(後方互換)。
|
||||
// spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||
addColumnIfMissing(db, 'jobs', 'required_worker_id', () => {
|
||||
db.exec('ALTER TABLE jobs ADD COLUMN required_worker_id TEXT');
|
||||
});
|
||||
addColumnIfMissing(db, 'jobs', 'reasoning_effort', () => {
|
||||
db.exec('ALTER TABLE jobs ADD COLUMN reasoning_effort TEXT');
|
||||
});
|
||||
addColumnIfMissing(db, 'local_tasks', 'llm_worker_id', () => {
|
||||
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_worker_id TEXT');
|
||||
});
|
||||
addColumnIfMissing(db, 'local_tasks', 'llm_effort', () => {
|
||||
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_effort TEXT');
|
||||
});
|
||||
|
||||
migrateMcpTables(db);
|
||||
migrateSshTables(db);
|
||||
migrateDashboardWidgets(db);
|
||||
@ -230,8 +246,10 @@ export function runMigrations(db: Database.Database): void {
|
||||
migrateLlmUsageDaily(db);
|
||||
migrateLlmUsageHourly(db);
|
||||
migrateSpaces(db);
|
||||
migrateSpaceUserPrefs(db);
|
||||
migrateSpaceMembers(db);
|
||||
migrateCalendarEvents(db);
|
||||
migrateAgentReminders(db);
|
||||
migrateSpaceInvites(db);
|
||||
migrateAppShareLinks(db);
|
||||
migratePrivatizeSpaceRows(db);
|
||||
@ -243,6 +261,8 @@ export function runMigrations(db: Database.Database): void {
|
||||
migrateTaskCommentIndex(db);
|
||||
migrateBackfillTaskCommentIndex(db);
|
||||
migrateA2aTaskDelegationColumns(db);
|
||||
migrateSpaceWebhooksTables(db);
|
||||
migrateChatConnectorBindings(db);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -449,6 +469,26 @@ function migrateSpaces(db: Database.Database): void {
|
||||
backfillBrowserSessionProfileSpaceIds(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* スペース表示設定(ユーザー別)。お気に入り・非表示は権限に影響しない
|
||||
* 一覧整理用の状態として保存する。schema.sql と Repository.initSchema にミラーがある。
|
||||
*/
|
||||
function migrateSpaceUserPrefs(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS space_user_prefs (
|
||||
user_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL,
|
||||
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0,1)),
|
||||
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0,1)),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, space_id),
|
||||
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_user ON space_user_prefs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_space ON space_user_prefs(space_id);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* スペース・メンバー(協働者)テーブルを追加する。追加のみ・冪等。
|
||||
* owner は members 行を持たず owner_id で判定する追加協働者の表。
|
||||
@ -501,6 +541,23 @@ function migrateCalendarEvents(db: Database.Database): void {
|
||||
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
|
||||
db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
|
||||
});
|
||||
addColumnIfMissing(db, 'calendar_events', 'reminder_minutes', () => {
|
||||
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER");
|
||||
});
|
||||
addColumnIfMissing(db, 'calendar_events', 'reminder_delivered_at', () => {
|
||||
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_delivered_at TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
function migrateAgentReminders(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||
id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT,
|
||||
title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL,
|
||||
delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1352,6 +1409,37 @@ function migratePushNotificationsTables(db: Database.Database): void {
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ワークスペース単位の Webhook 通知(issue #797, PR1)。
|
||||
* dual-path: schema.sql の CREATE TABLE と同一定義(三重ミラー; memory:
|
||||
* project_db_migration_dual_path)。冪等(CREATE TABLE IF NOT EXISTS)。
|
||||
* url_enc は AES-256-GCM で暗号化された Webhook URL(src/mcp/crypto.ts)。
|
||||
* 平文 URL は決して保存・ログ出力しない。
|
||||
*/
|
||||
function migrateSpaceWebhooksTables(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS space_webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'discord',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
url_enc BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
events TEXT NOT NULL DEFAULT '["succeeded","failed","waiting_human"]',
|
||||
include_details INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
disabled_reason TEXT,
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* a2a_tasks に委任列を追加(Plan 2C-1 Task 2)。
|
||||
* dual-path: schema.sql の CREATE TABLE にも同じ列を追加済み。
|
||||
@ -1368,3 +1456,38 @@ function migrateA2aTaskDelegationColumns(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* chat_connector_bindings (issue #801, PR1 — Slack MVP).
|
||||
* dual-path: schema.sql has the same CREATE TABLE for fresh DBs.
|
||||
*/
|
||||
function migrateChatConnectorBindings(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_connector_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL DEFAULT 'slack',
|
||||
external_workspace_id TEXT NOT NULL,
|
||||
external_channel_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
a2a_client_id TEXT NOT NULL,
|
||||
a2a_delegation_id TEXT NOT NULL,
|
||||
a2a_grant_id TEXT NOT NULL,
|
||||
bot_credentials_enc BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel
|
||||
ON chat_connector_bindings (platform, external_workspace_id, external_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id);
|
||||
CREATE TABLE IF NOT EXISTS chat_connector_processed_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at
|
||||
ON chat_connector_processed_events (received_at);
|
||||
`);
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ export interface CalendarEvent {
|
||||
endDate: string | null; // 終了日 YYYY-MM-DD。null = date と同じ(単日)
|
||||
time: string | null; // 開始 HH:MM。null = 終日
|
||||
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null)
|
||||
reminderMinutes: number | null;
|
||||
reminderDeliveredAt: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
createdBy: 'user' | 'agent';
|
||||
@ -41,6 +43,8 @@ export interface CalendarEventRow {
|
||||
end_date: string | null;
|
||||
time: string | null;
|
||||
end_time: string | null;
|
||||
reminder_minutes: number | null;
|
||||
reminder_delivered_at: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
created_by: string;
|
||||
@ -80,6 +84,8 @@ export function rowToCalendarEvent(row: CalendarEventRow): CalendarEvent {
|
||||
endDate: row.end_date ?? null,
|
||||
time: row.time ?? null,
|
||||
endTime: row.end_time ?? null,
|
||||
reminderMinutes: row.reminder_minutes ?? null,
|
||||
reminderDeliveredAt: row.reminder_delivered_at ?? null,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
createdBy: row.created_by === 'agent' ? 'agent' : 'user',
|
||||
@ -145,6 +151,7 @@ export interface CrossSpaceCalendarMonth {
|
||||
endDate?: string | null;
|
||||
time?: string | null;
|
||||
endTime?: string | null;
|
||||
reminderMinutes?: number | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
createdBy?: 'user' | 'agent';
|
||||
@ -157,8 +164,8 @@ export interface CrossSpaceCalendarMonth {
|
||||
const endTime = time ? (params.endTime ?? null) : null;
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, title, description, created_by, source_task_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO calendar_events (space_id, owner_id, date, end_date, time, end_time, reminder_minutes, title, description, created_by, source_task_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.spaceId,
|
||||
@ -167,6 +174,7 @@ export interface CrossSpaceCalendarMonth {
|
||||
endDate,
|
||||
time,
|
||||
endTime,
|
||||
params.reminderMinutes ?? null,
|
||||
params.title,
|
||||
params.description ?? null,
|
||||
params.createdBy ?? 'user',
|
||||
@ -210,7 +218,7 @@ export interface CrossSpaceCalendarMonth {
|
||||
}
|
||||
|
||||
|
||||
export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||
export async function updateCalendarEvent(db: Database.Database, eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||
const sets: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (fields.date !== undefined) {
|
||||
@ -244,6 +252,13 @@ export interface CrossSpaceCalendarMonth {
|
||||
sets.push('description = ?');
|
||||
args.push(fields.description);
|
||||
}
|
||||
if (fields.reminderMinutes !== undefined) {
|
||||
sets.push('reminder_minutes = ?');
|
||||
args.push(fields.reminderMinutes);
|
||||
}
|
||||
if (fields.date !== undefined || fields.time !== undefined || fields.reminderMinutes !== undefined) {
|
||||
sets.push('reminder_delivered_at = NULL');
|
||||
}
|
||||
if (sets.length === 0) return getCalendarEvent(db, eventId);
|
||||
sets.push(`updated_at = datetime('now')`);
|
||||
args.push(eventId);
|
||||
@ -256,6 +271,31 @@ export interface CrossSpaceCalendarMonth {
|
||||
db.prepare(`DELETE FROM calendar_events WHERE id = ?`).run(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定したローカル時刻までに到来した予定を一度だけ claim する。
|
||||
* UPDATE の条件にも未配信を入れるため、複数タブ/プロセスが同時に呼んでも重複しない。
|
||||
*/
|
||||
export async function claimDueCalendarReminders(db: Database.Database, spaceId: string, nowLocal: string): Promise<CalendarEvent[]> {
|
||||
const claim = db.transaction(() => {
|
||||
const candidates = db.prepare(
|
||||
`SELECT * FROM calendar_events
|
||||
WHERE space_id = ? AND time IS NOT NULL AND reminder_minutes IS NOT NULL
|
||||
AND reminder_delivered_at IS NULL
|
||||
AND datetime(date || ' ' || time, '-' || reminder_minutes || ' minutes') <= datetime(?)`
|
||||
).all(spaceId, nowLocal) as CalendarEventRow[];
|
||||
const updated: CalendarEvent[] = [];
|
||||
const mark = db.prepare(
|
||||
`UPDATE calendar_events SET reminder_delivered_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ? AND reminder_delivered_at IS NULL`
|
||||
);
|
||||
for (const row of candidates) {
|
||||
if (mark.run(row.id).changes === 1) updated.push(rowToCalendarEvent({ ...row, reminder_delivered_at: new Date().toISOString() }));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return claim();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 月ビューの日別カウント。`days` は date → {taskCount, eventCount}。
|
||||
|
||||
140
src/db/repositories/chat-connectors.test.ts
Normal file
140
src/db/repositories/chat-connectors.test.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Repository } from '../repository.js';
|
||||
|
||||
describe('chat_connector_bindings repository', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let spaceId = '';
|
||||
let clientId = '';
|
||||
let delegationId = '';
|
||||
let grantId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-chat-connectors-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
clientId = `a2a_${randomUUID()}`;
|
||||
repo.createA2aClient({
|
||||
clientId, name: 'Test App', redirectUris: ['https://example.com/cb'],
|
||||
secretHash: null, tokenEndpointAuthMethod: 'none', agentCardUrl: null, issuer: null,
|
||||
keyFingerprint: null, status: 'active', createdBy: null,
|
||||
});
|
||||
delegationId = randomUUID();
|
||||
grantId = randomUUID();
|
||||
repo.createA2aDelegation({
|
||||
id: delegationId, userId: owner.id, clientId, grantId,
|
||||
grantedSpaceIds: [spaceId], grantedSkills: ['chat'],
|
||||
audience: null, expiresAt: null, revokedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
|
||||
});
|
||||
|
||||
const credsEnc = () => Buffer.from('fake-ciphertext-not-real-crypto');
|
||||
|
||||
it('creates a binding and returns the full record (credentials as an opaque Buffer)', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(), createdBy: 'admin1',
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.status).toBe('active');
|
||||
expect(created.botCredentialsEnc).toBeInstanceOf(Buffer);
|
||||
expect(repo.getChatConnectorBindingById(created.id)).toEqual(created);
|
||||
});
|
||||
|
||||
it('findActiveChatConnectorBinding resolves by (platform, team, channel)', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
const found = repo.findActiveChatConnectorBinding('slack', 'T1', 'C1');
|
||||
expect(found?.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('findActiveChatConnectorBinding returns null for an unknown (team, channel)', () => {
|
||||
expect(repo.findActiveChatConnectorBinding('slack', 'T-unknown', 'C-unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('findActiveChatConnectorBinding returns null once the binding is disabled (fail-closed)', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
repo.updateChatConnectorBinding(created.id, { status: 'disabled' });
|
||||
expect(repo.findActiveChatConnectorBinding('slack', 'T1', 'C1')).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces a unique (platform, team, channel) constraint', () => {
|
||||
repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
expect(() => repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
})).toThrow(/UNIQUE constraint failed/);
|
||||
});
|
||||
|
||||
it('updateChatConnectorBinding re-encrypts credentials and bumps updated_at', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
const newCreds = Buffer.from('rotated-ciphertext');
|
||||
const updated = repo.updateChatConnectorBinding(created.id, { botCredentialsEnc: newCreds });
|
||||
expect(updated?.botCredentialsEnc.equals(newCreds)).toBe(true);
|
||||
});
|
||||
|
||||
it('listChatConnectorBindingsForDelegation returns bindings backed by a given delegation', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
const list = repo.listChatConnectorBindingsForDelegation(delegationId);
|
||||
expect(list.map(b => b.id)).toEqual([created.id]);
|
||||
expect(repo.listChatConnectorBindingsForDelegation('nonexistent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteChatConnectorBinding removes the row', () => {
|
||||
const created = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
repo.deleteChatConnectorBinding(created.id);
|
||||
expect(repo.getChatConnectorBindingById(created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('listChatConnectorBindings lists all bindings regardless of status', () => {
|
||||
const a = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C1',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
repo.updateChatConnectorBinding(a.id, { status: 'disabled' });
|
||||
const b = repo.createChatConnectorBinding({
|
||||
platform: 'slack', externalWorkspaceId: 'T1', externalChannelId: 'C2',
|
||||
spaceId, a2aClientId: clientId, a2aDelegationId: delegationId, a2aGrantId: grantId,
|
||||
botCredentialsEnc: credsEnc(),
|
||||
});
|
||||
const all = repo.listChatConnectorBindings();
|
||||
expect(all.map(x => x.id).sort()).toEqual([a.id, b.id].sort());
|
||||
});
|
||||
});
|
||||
206
src/db/repositories/chat-connectors.ts
Normal file
206
src/db/repositories/chat-connectors.ts
Normal file
@ -0,0 +1,206 @@
|
||||
// chat_connector_bindings repository domain (issue #801, PR1 — Slack MVP).
|
||||
// Follows the same sub-repository + thin-delegation-on-Repository pattern as
|
||||
// webhooks.ts / space_webhooks.
|
||||
//
|
||||
// SECURITY: bot_credentials_enc is an AES-256-GCM envelope-encrypted BLOB
|
||||
// (src/mcp/crypto.ts, same scheme as space_webhooks.url_enc). This module
|
||||
// never decrypts — callers (chat-connector-service.ts) decrypt just-in-time
|
||||
// and never log or return the plaintext. rowToChatConnectorBinding() keeps
|
||||
// bot_credentials_enc as an opaque Buffer; the admin API layer
|
||||
// (chat-connector-bindings-api.ts) must never place it, or a decrypted
|
||||
// credential, into a JSON response.
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type ChatConnectorPlatform = 'slack';
|
||||
export type ChatConnectorBindingStatus = 'active' | 'disabled';
|
||||
|
||||
export interface ChatConnectorBindingRecord {
|
||||
id: string;
|
||||
platform: ChatConnectorPlatform;
|
||||
externalWorkspaceId: string;
|
||||
externalChannelId: string;
|
||||
spaceId: string;
|
||||
a2aClientId: string;
|
||||
a2aDelegationId: string;
|
||||
a2aGrantId: string;
|
||||
botCredentialsEnc: Buffer;
|
||||
keyVersion: number;
|
||||
status: ChatConnectorBindingStatus;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateChatConnectorBindingInput {
|
||||
platform: ChatConnectorPlatform;
|
||||
externalWorkspaceId: string;
|
||||
externalChannelId: string;
|
||||
spaceId: string;
|
||||
a2aClientId: string;
|
||||
a2aDelegationId: string;
|
||||
a2aGrantId: string;
|
||||
botCredentialsEnc: Buffer;
|
||||
keyVersion?: number;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateChatConnectorBindingInput {
|
||||
/** Present only when the caller re-entered credentials (write-only). */
|
||||
botCredentialsEnc?: Buffer;
|
||||
keyVersion?: number;
|
||||
status?: ChatConnectorBindingStatus;
|
||||
}
|
||||
|
||||
interface ChatConnectorBindingRow {
|
||||
id: string;
|
||||
platform: string;
|
||||
external_workspace_id: string;
|
||||
external_channel_id: string;
|
||||
space_id: string;
|
||||
a2a_client_id: string;
|
||||
a2a_delegation_id: string;
|
||||
a2a_grant_id: string;
|
||||
bot_credentials_enc: Buffer;
|
||||
key_version: number;
|
||||
status: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function rowToBinding(row: ChatConnectorBindingRow): ChatConnectorBindingRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
platform: row.platform as ChatConnectorPlatform,
|
||||
externalWorkspaceId: row.external_workspace_id,
|
||||
externalChannelId: row.external_channel_id,
|
||||
spaceId: row.space_id,
|
||||
a2aClientId: row.a2a_client_id,
|
||||
a2aDelegationId: row.a2a_delegation_id,
|
||||
a2aGrantId: row.a2a_grant_id,
|
||||
botCredentialsEnc: row.bot_credentials_enc,
|
||||
keyVersion: row.key_version,
|
||||
status: row.status as ChatConnectorBindingStatus,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = `id, platform, external_workspace_id, external_channel_id, space_id,
|
||||
a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version,
|
||||
status, created_by, created_at, updated_at`;
|
||||
|
||||
export function createChatConnectorBinding(
|
||||
db: Database.Database,
|
||||
input: CreateChatConnectorBindingInput,
|
||||
): ChatConnectorBindingRecord {
|
||||
const id = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO chat_connector_bindings
|
||||
(id, platform, external_workspace_id, external_channel_id, space_id,
|
||||
a2a_client_id, a2a_delegation_id, a2a_grant_id, bot_credentials_enc, key_version, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
input.platform,
|
||||
input.externalWorkspaceId,
|
||||
input.externalChannelId,
|
||||
input.spaceId,
|
||||
input.a2aClientId,
|
||||
input.a2aDelegationId,
|
||||
input.a2aGrantId,
|
||||
input.botCredentialsEnc,
|
||||
input.keyVersion ?? 1,
|
||||
input.createdBy ?? null,
|
||||
);
|
||||
return getChatConnectorBindingById(db, id)!;
|
||||
}
|
||||
|
||||
export function listChatConnectorBindings(db: Database.Database): ChatConnectorBindingRecord[] {
|
||||
const rows = db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings ORDER BY created_at ASC`)
|
||||
.all() as ChatConnectorBindingRow[];
|
||||
return rows.map(rowToBinding);
|
||||
}
|
||||
|
||||
export function getChatConnectorBindingById(db: Database.Database, id: string): ChatConnectorBindingRecord | null {
|
||||
const row = db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE id = ?`)
|
||||
.get(id) as ChatConnectorBindingRow | undefined;
|
||||
return row ? rowToBinding(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup used by the inbound mention handler: (platform, team, channel) → binding.
|
||||
* Only returns 'active' bindings — disabled bindings must be invisible to the
|
||||
* inbound path (fail-closed; the caller should treat a null return as "ignore").
|
||||
*/
|
||||
export function findActiveChatConnectorBinding(
|
||||
db: Database.Database,
|
||||
platform: ChatConnectorPlatform,
|
||||
externalWorkspaceId: string,
|
||||
externalChannelId: string,
|
||||
): ChatConnectorBindingRecord | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings
|
||||
WHERE platform = ? AND external_workspace_id = ? AND external_channel_id = ? AND status = 'active'`,
|
||||
)
|
||||
.get(platform, externalWorkspaceId, externalChannelId) as ChatConnectorBindingRow | undefined;
|
||||
return row ? rowToBinding(row) : null;
|
||||
}
|
||||
|
||||
/** Every delegation the schema allows to back at most one space (validated at the API layer). */
|
||||
export function listChatConnectorBindingsForDelegation(
|
||||
db: Database.Database,
|
||||
a2aDelegationId: string,
|
||||
): ChatConnectorBindingRecord[] {
|
||||
const rows = db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM chat_connector_bindings WHERE a2a_delegation_id = ?`)
|
||||
.all(a2aDelegationId) as ChatConnectorBindingRow[];
|
||||
return rows.map(rowToBinding);
|
||||
}
|
||||
|
||||
export function updateChatConnectorBinding(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
patch: UpdateChatConnectorBindingInput,
|
||||
): ChatConnectorBindingRecord | null {
|
||||
const sets: string[] = [];
|
||||
const params: Array<string | number | Buffer> = [];
|
||||
if (patch.botCredentialsEnc !== undefined) {
|
||||
sets.push('bot_credentials_enc = ?');
|
||||
params.push(patch.botCredentialsEnc);
|
||||
}
|
||||
if (patch.keyVersion !== undefined) {
|
||||
sets.push('key_version = ?');
|
||||
params.push(patch.keyVersion);
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
sets.push('status = ?');
|
||||
params.push(patch.status);
|
||||
}
|
||||
if (sets.length === 0) return getChatConnectorBindingById(db, id);
|
||||
sets.push("updated_at = datetime('now')");
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE chat_connector_bindings SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
return getChatConnectorBindingById(db, id);
|
||||
}
|
||||
|
||||
export function deleteChatConnectorBinding(db: Database.Database, id: string): void {
|
||||
db.prepare('DELETE FROM chat_connector_bindings WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/** Atomically reserves a Slack envelope. False means a retry already ran. */
|
||||
export function claimChatConnectorEvent(db: Database.Database, eventId: string): boolean {
|
||||
// Slack retries are bounded to minutes. Retaining a week absorbs delayed
|
||||
// retries without letting an external event stream grow the DB forever.
|
||||
db.prepare("DELETE FROM chat_connector_processed_events WHERE received_at < datetime('now', '-7 days')").run();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO chat_connector_processed_events (event_id) VALUES (?)
|
||||
ON CONFLICT(event_id) DO NOTHING`,
|
||||
).run(eventId);
|
||||
return result.changes === 1;
|
||||
}
|
||||
227
src/db/repositories/jobs.claim-pin.test.ts
Normal file
227
src/db/repositories/jobs.claim-pin.test.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Repository } from '../repository.js';
|
||||
|
||||
// Task 8: claim SQL 共通フラグメント化 + required_worker_id pin 条件。
|
||||
// claimNextJob / claimNextRetryJob / peekNextClaimable の3クエリ(4箇所)が
|
||||
// 同じマッチング条件(CLAIMABLE_MATCH_SQL)を共有していることを、pin あり/なし
|
||||
// 双方のケースで確認する。
|
||||
describe('claim SQL: required_worker_id pin (Phase 1 Task 8)', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'jobs-claim-pin-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedWorker(opts: {
|
||||
workerId: string;
|
||||
roles: string[];
|
||||
enabled?: boolean;
|
||||
healthy?: boolean;
|
||||
}): Promise<void> {
|
||||
await r.upsertWorkerNode({
|
||||
workerId: opts.workerId,
|
||||
endpoint: `http://${opts.workerId}.local:11434`,
|
||||
enabled: opts.enabled ?? true,
|
||||
healthy: opts.healthy ?? true,
|
||||
roles: opts.roles,
|
||||
});
|
||||
}
|
||||
|
||||
describe('claimNextJob', () => {
|
||||
it('1) unpinned job: claimed by a worker whose profile_tags match required_profile (baseline unchanged)', async () => {
|
||||
await seedWorker({ workerId: 'worker-auto-1', roles: ['auto'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-1',
|
||||
issueNumber: 1,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
} as any);
|
||||
expect(job.requiredWorkerId).toBeNull();
|
||||
|
||||
const claimed = await r.claimNextJob('worker-auto-1');
|
||||
expect(claimed?.id).toBe(job.id);
|
||||
});
|
||||
|
||||
it('2) pinned job: only the pinned worker can claim it; a different enabled worker with matching profile cannot', async () => {
|
||||
await seedWorker({ workerId: 'worker-pinned', roles: ['auto'] });
|
||||
await seedWorker({ workerId: 'worker-other', roles: ['auto'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-2',
|
||||
issueNumber: 2,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-pinned',
|
||||
} as any);
|
||||
expect(job.requiredWorkerId).toBe('worker-pinned');
|
||||
|
||||
// The non-pinned worker must NOT be able to claim it, even though its
|
||||
// profile_tags match required_profile.
|
||||
const claimedByOther = await r.claimNextJob('worker-other');
|
||||
expect(claimedByOther).toBeNull();
|
||||
|
||||
// The pinned worker CAN claim it.
|
||||
const claimedByPinned = await r.claimNextJob('worker-pinned');
|
||||
expect(claimedByPinned?.id).toBe(job.id);
|
||||
});
|
||||
|
||||
it('3) pinned worker without any execution role (auto/fast/quality) cannot claim (stays queued)', async () => {
|
||||
await seedWorker({ workerId: 'worker-reflection-only', roles: ['reflection'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-3',
|
||||
issueNumber: 3,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-reflection-only',
|
||||
} as any);
|
||||
|
||||
const claimed = await r.claimNextJob('worker-reflection-only');
|
||||
expect(claimed).toBeNull();
|
||||
|
||||
const stillQueued = await r.getJob(job.id);
|
||||
expect(stillQueued?.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('3b) pinned worker with title-only role cannot claim either', async () => {
|
||||
await seedWorker({ workerId: 'worker-title-only', roles: ['title'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-3b',
|
||||
issueNumber: 4,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-title-only',
|
||||
} as any);
|
||||
|
||||
const claimed = await r.claimNextJob('worker-title-only');
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
|
||||
it('4) pinned worker disabled: not claimable', async () => {
|
||||
await seedWorker({ workerId: 'worker-disabled', roles: ['auto'], enabled: false });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-4',
|
||||
issueNumber: 5,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-disabled',
|
||||
} as any);
|
||||
|
||||
const claimed = await r.claimNextJob('worker-disabled');
|
||||
expect(claimed).toBeNull();
|
||||
|
||||
const stillQueued = await r.getJob(job.id);
|
||||
expect(stillQueued?.status).toBe('queued');
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimNextRetryJob', () => {
|
||||
async function seedRetryJob(params: {
|
||||
repo: string;
|
||||
issueNumber: number;
|
||||
requiredWorkerId?: string | null;
|
||||
}) {
|
||||
const job = await r.createJob({
|
||||
repo: params.repo,
|
||||
issueNumber: params.issueNumber,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: params.requiredWorkerId ?? null,
|
||||
} as any);
|
||||
const pastRetryAt = new Date(Date.now() - 60_000).toISOString();
|
||||
await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 });
|
||||
return job;
|
||||
}
|
||||
|
||||
it('2) pinned retry job: only the pinned worker can claim it', async () => {
|
||||
await seedWorker({ workerId: 'worker-pinned-r', roles: ['auto'] });
|
||||
await seedWorker({ workerId: 'worker-other-r', roles: ['auto'] });
|
||||
const job = await seedRetryJob({ repo: 'local/pin-retry-2', issueNumber: 1, requiredWorkerId: 'worker-pinned-r' });
|
||||
|
||||
const claimedByOther = await r.claimNextRetryJob('worker-other-r');
|
||||
expect(claimedByOther).toBeNull();
|
||||
|
||||
const claimedByPinned = await r.claimNextRetryJob('worker-pinned-r');
|
||||
expect(claimedByPinned?.id).toBe(job.id);
|
||||
});
|
||||
|
||||
it('3) pinned retry job: worker without execution role cannot claim', async () => {
|
||||
await seedWorker({ workerId: 'worker-reflection-r', roles: ['reflection'] });
|
||||
await seedRetryJob({ repo: 'local/pin-retry-3', issueNumber: 2, requiredWorkerId: 'worker-reflection-r' });
|
||||
|
||||
const claimed = await r.claimNextRetryJob('worker-reflection-r');
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('peekNextClaimable', () => {
|
||||
it('2) pinned queued job: peek returns it for the pinned worker but not for another', async () => {
|
||||
await seedWorker({ workerId: 'worker-pinned-p', roles: ['auto'] });
|
||||
await seedWorker({ workerId: 'worker-other-p', roles: ['auto'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-peek-2',
|
||||
issueNumber: 1,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-pinned-p',
|
||||
} as any);
|
||||
|
||||
const peekOther = await r.peekNextClaimable('worker-other-p');
|
||||
expect(peekOther).toBeNull();
|
||||
|
||||
const peekPinned = await r.peekNextClaimable('worker-pinned-p');
|
||||
expect(peekPinned?.id).toBe(job.id);
|
||||
});
|
||||
|
||||
it('3) pinned queued job: worker without execution role does not peek it', async () => {
|
||||
await seedWorker({ workerId: 'worker-reflection-p', roles: ['reflection'] });
|
||||
await r.createJob({
|
||||
repo: 'local/pin-peek-3',
|
||||
issueNumber: 2,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-reflection-p',
|
||||
} as any);
|
||||
|
||||
const peek = await r.peekNextClaimable('worker-reflection-p');
|
||||
expect(peek).toBeNull();
|
||||
});
|
||||
|
||||
it('2r) pinned retry job takes priority in peek and is scoped to the pinned worker', async () => {
|
||||
await seedWorker({ workerId: 'worker-pinned-pr', roles: ['auto'] });
|
||||
await seedWorker({ workerId: 'worker-other-pr', roles: ['auto'] });
|
||||
const job = await r.createJob({
|
||||
repo: 'local/pin-peek-retry-2',
|
||||
issueNumber: 3,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
role: 'auto',
|
||||
requiredWorkerId: 'worker-pinned-pr',
|
||||
} as any);
|
||||
const pastRetryAt = new Date(Date.now() - 60_000).toISOString();
|
||||
await r.updateJob(job.id, { status: 'retry', nextRetryAt: pastRetryAt, attempt: 2 });
|
||||
|
||||
const peekOther = await r.peekNextClaimable('worker-other-pr');
|
||||
expect(peekOther).toBeNull();
|
||||
|
||||
const peekPinned = await r.peekNextClaimable('worker-pinned-pr');
|
||||
expect(peekPinned?.id).toBe(job.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/db/repositories/jobs.llm-selection.test.ts
Normal file
55
src/db/repositories/jobs.llm-selection.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Repository } from '../repository.js';
|
||||
|
||||
describe('jobs.required_worker_id / reasoning_effort (LLM selection Phase 1)', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'jobs-llm-selection-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('createJob accepts requiredWorkerId + reasoningEffort and getJob round-trips both', async () => {
|
||||
const created = await r.createJob({
|
||||
repo: 'local/llm-select-1',
|
||||
issueNumber: 1,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
requiredWorkerId: 'worker-gpu-2',
|
||||
reasoningEffort: 'high',
|
||||
} as any);
|
||||
|
||||
expect(created.requiredWorkerId).toBe('worker-gpu-2');
|
||||
expect(created.reasoningEffort).toBe('high');
|
||||
|
||||
const fetched = await r.getJob(created.id);
|
||||
expect(fetched).toBeTruthy();
|
||||
expect(fetched!.requiredWorkerId).toBe('worker-gpu-2');
|
||||
expect(fetched!.reasoningEffort).toBe('high');
|
||||
});
|
||||
|
||||
it('createJob defaults requiredWorkerId/reasoningEffort to null when omitted', async () => {
|
||||
const created = await r.createJob({
|
||||
repo: 'local/llm-select-2',
|
||||
issueNumber: 2,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
} as any);
|
||||
|
||||
expect(created.requiredWorkerId).toBeNull();
|
||||
expect(created.reasoningEffort).toBeNull();
|
||||
|
||||
const fetched = await r.getJob(created.id);
|
||||
expect(fetched!.requiredWorkerId).toBeNull();
|
||||
expect(fetched!.reasoningEffort).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -62,6 +62,17 @@ export interface Job {
|
||||
requiredRole: JobRole;
|
||||
/** @deprecated Use requiredRole */
|
||||
requiredProfile: JobRole;
|
||||
/**
|
||||
* LLM 選択 Phase 1: pinned worker id for this job. NULL = 従来の
|
||||
* profile ルーティング(自動選択)。設定→ワーカー/エフォート選択で
|
||||
* ユーザーが明示ピンした場合のみ非 null。claim SQL での消費は後続タスク。
|
||||
*/
|
||||
requiredWorkerId: string | null;
|
||||
/**
|
||||
* LLM 選択 Phase 1: per-job pinned reasoning effort (e.g. 'low' | 'medium' |
|
||||
* 'high', worker の reasoning_efforts に依存). NULL = ワーカー既定を使用。
|
||||
*/
|
||||
reasoningEffort: string | null;
|
||||
ownerId: string | null;
|
||||
visibility: 'private' | 'org' | 'public';
|
||||
visibilityScopeOrgId: string | null;
|
||||
@ -104,6 +115,10 @@ export interface CreateJobParams {
|
||||
spaceId?: string | null;
|
||||
/** 実行ログ root(計画5)。NULL = 後方互換で workspacePath/logs に解決。 */
|
||||
runtimeDir?: string | null;
|
||||
/** LLM 選択 Phase 1: pinned worker id。未指定/null は従来の profile ルーティング。 */
|
||||
requiredWorkerId?: string | null;
|
||||
/** LLM 選択 Phase 1: per-job pinned reasoning effort。未指定/null はワーカー既定。 */
|
||||
reasoningEffort?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@ -148,6 +163,8 @@ export interface JobRow {
|
||||
subtask_depth: number;
|
||||
required_profile: string;
|
||||
task_class: string;
|
||||
required_worker_id: string | null;
|
||||
reasoning_effort: string | null;
|
||||
owner_id: string | null;
|
||||
visibility: string | null;
|
||||
visibility_scope_org_id: string | null;
|
||||
@ -209,6 +226,8 @@ export function rowToJob(row: JobRow): Job {
|
||||
subtaskDepth: row.subtask_depth ?? 0,
|
||||
requiredRole: normalizeJobRole(row.required_profile),
|
||||
requiredProfile: normalizeJobRole(row.required_profile),
|
||||
requiredWorkerId: row.required_worker_id ?? null,
|
||||
reasoningEffort: row.reasoning_effort ?? null,
|
||||
ownerId: row.owner_id ?? null,
|
||||
visibility: (row.visibility === 'org' || row.visibility === 'public' ? row.visibility : 'private'),
|
||||
visibilityScopeOrgId: row.visibility_scope_org_id ?? null,
|
||||
@ -243,8 +262,8 @@ export function rowToJob(row: JobRow): Job {
|
||||
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, created_at, updated_at)
|
||||
VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @now, @now)`
|
||||
`INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class, instruction, attempt, max_attempts, resume_movement, ask_count, worker_id, parent_job_id, continued_from_job_id, subtask_depth, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, task_kind, payload, space_id, runtime_dir, required_worker_id, reasoning_effort, created_at, updated_at)
|
||||
VALUES (@id, @repo, @issueNumber, 'queued', @pieceName, @requiredRole, 'auto', @instruction, 1, @maxAttempts, @resumeMovement, @askCount, NULL, @parentJobId, @continuedFromJobId, @subtaskDepth, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @taskKind, @payload, @spaceId, @runtimeDir, @requiredWorkerId, @reasoningEffort, @now, @now)`
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
@ -267,6 +286,8 @@ export function rowToJob(row: JobRow): Job {
|
||||
payload: params.payload ?? null,
|
||||
spaceId: params.spaceId ?? null,
|
||||
runtimeDir: params.runtimeDir ?? null,
|
||||
requiredWorkerId: params.requiredWorkerId ?? null,
|
||||
reasoningEffort: params.reasoningEffort ?? null,
|
||||
now,
|
||||
});
|
||||
|
||||
@ -566,6 +587,27 @@ export function rowToJob(row: JobRow): Job {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* queued/retry ジョブと worker のマッチング条件(claimNextJob /
|
||||
* claimNextRetryJob / peekNextClaimable の計4クエリで共有)。
|
||||
* - 直指定あり: そのワーカーのみ。かつ実行ロール(auto/fast/quality)を持つこと
|
||||
* (title/reflection 専用ワーカーがユーザー指定で通常ジョブを掴む/永久 queued を防ぐ)
|
||||
* - 直指定なし: 従来の required_profile マッチ
|
||||
*/
|
||||
const CLAIMABLE_MATCH_SQL = `
|
||||
w.enabled = 1
|
||||
AND w.healthy = 1
|
||||
AND (
|
||||
(j.required_worker_id IS NOT NULL
|
||||
AND j.required_worker_id = w.worker_id
|
||||
AND (instr(w.profile_tags, ',auto,') > 0
|
||||
OR instr(w.profile_tags, ',fast,') > 0
|
||||
OR instr(w.profile_tags, ',quality,') > 0))
|
||||
OR (j.required_worker_id IS NULL
|
||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0)
|
||||
)`;
|
||||
|
||||
|
||||
export async function claimNextJob(db: Database.Database, workerId: string): Promise<Job | null> {
|
||||
const row = db.prepare(`
|
||||
UPDATE jobs
|
||||
@ -575,9 +617,7 @@ export function rowToJob(row: JobRow): Job {
|
||||
FROM jobs j
|
||||
JOIN worker_nodes w ON w.worker_id = ?
|
||||
WHERE j.status = 'queued'
|
||||
AND w.enabled = 1
|
||||
AND w.healthy = 1
|
||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||
AND ${CLAIMABLE_MATCH_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_locks il
|
||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||
@ -604,9 +644,7 @@ export function rowToJob(row: JobRow): Job {
|
||||
JOIN worker_nodes w ON w.worker_id = ?
|
||||
WHERE j.status = 'retry'
|
||||
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
||||
AND w.enabled = 1
|
||||
AND w.healthy = 1
|
||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||
AND ${CLAIMABLE_MATCH_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_locks il
|
||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||
@ -633,9 +671,7 @@ export function rowToJob(row: JobRow): Job {
|
||||
JOIN worker_nodes w ON w.worker_id = ?
|
||||
WHERE j.status = 'retry'
|
||||
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
||||
AND w.enabled = 1
|
||||
AND w.healthy = 1
|
||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||
AND ${CLAIMABLE_MATCH_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_locks il
|
||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||
@ -650,9 +686,7 @@ export function rowToJob(row: JobRow): Job {
|
||||
FROM jobs j
|
||||
JOIN worker_nodes w ON w.worker_id = ?
|
||||
WHERE j.status = 'queued'
|
||||
AND w.enabled = 1
|
||||
AND w.healthy = 1
|
||||
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||
AND ${CLAIMABLE_MATCH_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM issue_locks il
|
||||
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||
@ -823,6 +857,43 @@ export function rowToJob(row: JobRow): Job {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* LLM 選択 Phase 1: PATCH /api/local/tasks/:id でタスクの sticky
|
||||
* worker/effort が変わったとき、まだ実行が始まっていないジョブ(queued /
|
||||
* retry)へ即反映する。running/dispatching/waiting_subtasks 中のジョブは
|
||||
* 対象外(既に pinned worker で claim 済み・走行中に差し替えると齟齬が
|
||||
* 出るため)。
|
||||
* サブタスク(repo='subtask/<jobId>')は parent_job_id チェーンで
|
||||
* タスク直下ジョブの子孫として再帰的に含める — SpawnSubTask がピンを
|
||||
* 継承するため、子孫が queued のまま停滞ワーカーを抱え続けると「ピンを
|
||||
* 解除すれば復旧する」という UI の案内が成立しなくなる(親が
|
||||
* waiting_subtasks で永久に止まる)。visibility cascade
|
||||
* (updateVisibilityForTaskCascade) と同じ再帰パターン。
|
||||
*/
|
||||
export async function updateJobsLlmSelectionForTask(
|
||||
db: Database.Database,
|
||||
taskId: number,
|
||||
sel: { workerId: string | null; effort: string | null },
|
||||
): Promise<void> {
|
||||
const repoName = localTaskRepoName(taskId);
|
||||
db
|
||||
.prepare(`
|
||||
WITH RECURSIVE task_tree(id) AS (
|
||||
SELECT id FROM jobs WHERE repo = @repo AND issue_number = @taskId
|
||||
UNION ALL
|
||||
SELECT j.id FROM jobs j JOIN task_tree t ON j.parent_job_id = t.id
|
||||
)
|
||||
UPDATE jobs
|
||||
SET required_worker_id = @workerId,
|
||||
reasoning_effort = @effort,
|
||||
updated_at = datetime('now')
|
||||
WHERE id IN (SELECT id FROM task_tree)
|
||||
AND status IN ('queued','retry')
|
||||
`)
|
||||
.run({ repo: repoName, taskId, workerId: sel.workerId, effort: sel.effort });
|
||||
}
|
||||
|
||||
|
||||
export async function getSubJobs(db: Database.Database, parentJobId: string): Promise<Job[]> {
|
||||
// 同一 issue_number に複数ジョブがある場合(ASK再投入等)、最新のみ返す
|
||||
// ROW_NUMBER() + rowid で同一 created_at でも一意に決定する
|
||||
|
||||
@ -69,6 +69,14 @@ export interface LocalTask {
|
||||
spaceId: string | null;
|
||||
/** 実行ログ root(計画5)。null = 後方互換で workspacePath/logs に解決。 */
|
||||
runtimeDir: string | null;
|
||||
/**
|
||||
* LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで
|
||||
* 新規生成される job にも引き継がれる(引き継ぎ配線は claim SQL/worker 側の
|
||||
* 後続タスク)。null = 未選択(従来の profile ルーティング)。
|
||||
*/
|
||||
llmWorkerId: string | null;
|
||||
/** LLM 選択 Phase 1: タスク単位のスティッキー effort。null = 未選択。 */
|
||||
llmEffort: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
feedbackRating: 'good' | 'bad' | null;
|
||||
@ -162,6 +170,13 @@ export interface CreateLocalTaskParams {
|
||||
spaceId?: string | null;
|
||||
/** Per-task runtime options (e.g. { mcpDisabled, skillsDisabled }). Stored as JSON. */
|
||||
options?: Record<string, unknown>;
|
||||
/**
|
||||
* LLM 選択 Phase 1: タスク作成時に pin する worker/effort。呼び出し側
|
||||
* (local-tasks-crud-api.ts の POST) が insert 前に validateLlmSelection で
|
||||
* 検証済みである前提。未指定/null は従来の profile ルーティング。
|
||||
*/
|
||||
llmWorkerId?: string | null;
|
||||
llmEffort?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@ -179,6 +194,8 @@ export interface LocalTaskRow {
|
||||
workspace_path: string | null;
|
||||
workspace_mode: string | null;
|
||||
runtime_dir: string | null;
|
||||
llm_worker_id: string | null;
|
||||
llm_effort: string | null;
|
||||
owner_id: string | null;
|
||||
owner_name?: string | null;
|
||||
visibility: string | null;
|
||||
@ -232,6 +249,8 @@ export function rowToLocalTask(row: LocalTaskRow): LocalTask {
|
||||
visibilityScopeOrgName: row.visibility_scope_org_name ?? null,
|
||||
spaceId: row.space_id ?? null,
|
||||
runtimeDir: row.runtime_dir ?? null,
|
||||
llmWorkerId: row.llm_worker_id ?? null,
|
||||
llmEffort: row.llm_effort ?? null,
|
||||
createdAt: utc(row.created_at),
|
||||
updatedAt: utc(row.updated_at),
|
||||
feedbackRating: (row.feedback_rating as 'good' | 'bad' | null) ?? null,
|
||||
@ -329,8 +348,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
||||
export async function createLocalTask(db: Database.Database, params: CreateLocalTaskParams): Promise<LocalTask> {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options)
|
||||
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options)`
|
||||
`INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, workspace_mode, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, space_id, options, llm_worker_id, llm_effort)
|
||||
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @workspaceMode, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @spaceId, @options, @llmWorkerId, @llmEffort)`
|
||||
)
|
||||
.run({
|
||||
title: params.title,
|
||||
@ -349,6 +368,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
||||
browserSessionProfileId: params.browserSessionProfileId ?? null,
|
||||
spaceId: params.spaceId ?? null,
|
||||
options: JSON.stringify(params.options ?? {}),
|
||||
llmWorkerId: params.llmWorkerId ?? null,
|
||||
llmEffort: params.llmEffort ?? null,
|
||||
});
|
||||
|
||||
const task = await getLocalTask(db, Number(result.lastInsertRowid));
|
||||
@ -680,6 +701,11 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
||||
subtask_depth: row.job_subtask_depth ?? 0,
|
||||
required_profile: row.job_required_profile!,
|
||||
task_class: row.job_task_class!,
|
||||
// この join は latestJob 表示用の縮小列セットで required_worker_id /
|
||||
// reasoning_effort は取得していない。pin 済み表示が必要になったら
|
||||
// 上の SELECT 列に j.required_worker_id / j.reasoning_effort を追加する。
|
||||
required_worker_id: null,
|
||||
reasoning_effort: null,
|
||||
owner_id: row.job_owner_id ?? null,
|
||||
visibility: row.job_visibility ?? null,
|
||||
visibility_scope_org_id: row.job_visibility_scope_org_id ?? null,
|
||||
@ -764,6 +790,8 @@ export function parseCommentAttachments(raw: string | null): string[] {
|
||||
runtimeDir: 'runtime_dir',
|
||||
visibility: 'visibility',
|
||||
visibilityScopeOrgId: 'visibility_scope_org_id',
|
||||
llmWorkerId: 'llm_worker_id',
|
||||
llmEffort: 'llm_effort',
|
||||
};
|
||||
|
||||
for (const [jsKey, dbCol] of Object.entries(fieldMap)) {
|
||||
|
||||
46
src/db/repositories/reminders.ts
Normal file
46
src/db/repositories/reminders.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export interface AgentReminder {
|
||||
id: string;
|
||||
ownerId: string | null;
|
||||
spaceId: string | null;
|
||||
title: string | null;
|
||||
body: string;
|
||||
imageUrl: string | null;
|
||||
dueAt: string;
|
||||
deliveredAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function fromRow(row: Record<string, unknown>): AgentReminder {
|
||||
return {
|
||||
id: String(row.id), ownerId: row.owner_id as string | null, spaceId: row.space_id as string | null,
|
||||
title: row.title as string | null, body: String(row.body), imageUrl: row.image_url as string | null,
|
||||
dueAt: String(row.due_at), deliveredAt: row.delivered_at as string | null,
|
||||
cancelledAt: row.cancelled_at as string | null, createdAt: String(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentReminder(db: Database.Database, input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder {
|
||||
const id = randomUUID();
|
||||
db.prepare('INSERT INTO agent_reminders (id, owner_id, space_id, title, body, image_url, due_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(id, input.ownerId ?? null, input.spaceId ?? null, input.title ?? null, input.body, input.imageUrl ?? null, input.dueAt);
|
||||
return fromRow(db.prepare('SELECT * FROM agent_reminders WHERE id = ?').get(id) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function listAgentReminders(db: Database.Database, ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] {
|
||||
const conditions = ['cancelled_at IS NULL', 'delivered_at IS NULL'];
|
||||
const args: unknown[] = [];
|
||||
if (ownerId == null) conditions.push('owner_id IS NULL');
|
||||
else { conditions.push('owner_id = ?'); args.push(ownerId); }
|
||||
if (spaceId != null) { conditions.push('space_id = ?'); args.push(spaceId); }
|
||||
return (db.prepare(`SELECT * FROM agent_reminders WHERE ${conditions.join(' AND ')} ORDER BY due_at ASC`).all(...args) as Record<string, unknown>[]).map(fromRow);
|
||||
}
|
||||
|
||||
export function cancelAgentReminder(db: Database.Database, id: string, ownerId: string | null | undefined): boolean {
|
||||
const scope = ownerId == null ? 'owner_id IS NULL' : 'owner_id = ?';
|
||||
const args = ownerId == null ? [id] : [id, ownerId];
|
||||
return db.prepare(`UPDATE agent_reminders SET cancelled_at = datetime('now') WHERE id = ? AND ${scope} AND delivered_at IS NULL AND cancelled_at IS NULL`).run(...args).changes > 0;
|
||||
}
|
||||
@ -98,6 +98,20 @@ const __dirname = dirname(__filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal';
|
||||
`);
|
||||
// スペース表示設定(ユーザー別)。schema.sql / migrate.ts と三重ミラー。
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS space_user_prefs (
|
||||
user_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL,
|
||||
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0,1)),
|
||||
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0,1)),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, space_id),
|
||||
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_user ON space_user_prefs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_space ON space_user_prefs(space_id);
|
||||
`);
|
||||
ensureColumn(db, 'local_tasks', 'space_id', 'TEXT');
|
||||
ensureColumn(db, 'local_tasks', 'workspace_mode', "TEXT NOT NULL DEFAULT 'persistent'");
|
||||
ensureColumn(db, 'jobs', 'space_id', 'TEXT');
|
||||
@ -145,6 +159,8 @@ const __dirname = dirname(__filename);
|
||||
end_date TEXT,
|
||||
time TEXT,
|
||||
end_time TEXT,
|
||||
reminder_minutes INTEGER,
|
||||
reminder_delivered_at TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by TEXT NOT NULL DEFAULT 'user',
|
||||
@ -153,6 +169,13 @@ const __dirname = dirname(__filename);
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||
id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT,
|
||||
title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL,
|
||||
delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||
`);
|
||||
|
||||
// スペース招待リンク(再利用トークン)。schema.sql / migrate.ts と三重ミラー。
|
||||
@ -456,6 +479,14 @@ const __dirname = dirname(__filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
|
||||
ON llm_usage_hourly (user_id, hour);
|
||||
`);
|
||||
|
||||
// LLM selection Phase 1: sticky worker/effort pin. Mirrors schema.sql +
|
||||
// migrate.ts (dual-path rule) — this is the third compat-upgrade path
|
||||
// that runs on every bare `new Repository()` (no runMigrations call).
|
||||
ensureColumn(db, 'jobs', 'required_worker_id', 'TEXT');
|
||||
ensureColumn(db, 'jobs', 'reasoning_effort', 'TEXT');
|
||||
ensureColumn(db, 'local_tasks', 'llm_worker_id', 'TEXT');
|
||||
ensureColumn(db, 'local_tasks', 'llm_effort', 'TEXT');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -36,6 +36,13 @@ export interface Space {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceDisplayPrefs {
|
||||
favorite: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface SpaceWithDisplayPrefs extends Space, SpaceDisplayPrefs {}
|
||||
|
||||
|
||||
export type SpaceMemberRoleValue = 'owner' | 'editor' | 'viewer';
|
||||
|
||||
@ -158,6 +165,68 @@ export function rowToSpace(row: SpaceRow): Space {
|
||||
}
|
||||
|
||||
|
||||
export async function listSpacesWithDisplayPrefs(db: Database.Database, opts: { viewer: Express.User; includeArchived?: boolean }): Promise<SpaceWithDisplayPrefs[]> {
|
||||
const viewerClause = buildSpaceVisibilityWhere(opts.viewer, 's');
|
||||
const statusClause = opts.includeArchived ? '1=1' : "s.status = 'open'";
|
||||
const rows = db
|
||||
.prepare(`
|
||||
SELECT s.*,
|
||||
COALESCE(p.favorite, 0) AS pref_favorite,
|
||||
COALESCE(p.hidden, 0) AS pref_hidden
|
||||
FROM spaces s
|
||||
LEFT JOIN space_user_prefs p
|
||||
ON p.space_id = s.id AND p.user_id = ?
|
||||
WHERE ${statusClause} AND ${viewerClause.clause}
|
||||
ORDER BY s.kind = 'personal' DESC, s.updated_at DESC
|
||||
`)
|
||||
.all(opts.viewer.id, ...viewerClause.params) as Array<SpaceRow & { pref_favorite: number; pref_hidden: number }>;
|
||||
return rows.map((row) => ({
|
||||
...rowToSpace(row),
|
||||
favorite: row.pref_favorite === 1,
|
||||
hidden: row.pref_hidden === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
export function getSpaceDisplayPrefs(db: Database.Database, userId: string, spaceId: string): SpaceDisplayPrefs {
|
||||
const row = db
|
||||
.prepare(`SELECT favorite, hidden FROM space_user_prefs WHERE user_id = ? AND space_id = ?`)
|
||||
.get(userId, spaceId) as { favorite: number; hidden: number } | undefined;
|
||||
return {
|
||||
favorite: row?.favorite === 1,
|
||||
hidden: row?.hidden === 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function updateSpaceDisplayPrefs(
|
||||
db: Database.Database,
|
||||
userId: string,
|
||||
spaceId: string,
|
||||
patch: Partial<SpaceDisplayPrefs>,
|
||||
): SpaceDisplayPrefs {
|
||||
const current = getSpaceDisplayPrefs(db, userId, spaceId);
|
||||
const hidden = patch.hidden ?? current.hidden;
|
||||
const favorite = hidden ? false : (patch.favorite ?? current.favorite);
|
||||
db
|
||||
.prepare(`
|
||||
INSERT INTO space_user_prefs (user_id, space_id, favorite, hidden, updated_at)
|
||||
VALUES (@userId, @spaceId, @favorite, @hidden, datetime('now'))
|
||||
ON CONFLICT(user_id, space_id) DO UPDATE SET
|
||||
favorite = excluded.favorite,
|
||||
hidden = excluded.hidden,
|
||||
updated_at = excluded.updated_at
|
||||
`)
|
||||
.run({
|
||||
userId,
|
||||
spaceId,
|
||||
favorite: favorite ? 1 : 0,
|
||||
hidden: hidden ? 1 : 0,
|
||||
});
|
||||
return { favorite, hidden };
|
||||
}
|
||||
|
||||
|
||||
export async function updateSpace(db: Database.Database, id: string, patch: { title?: string; description?: string; brandColor?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null }): Promise<Space | null> {
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
|
||||
157
src/db/repositories/webhooks.test.ts
Normal file
157
src/db/repositories/webhooks.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from '../repository.js';
|
||||
import { AUTO_DISABLE_FAILURE_THRESHOLD } from './webhooks.js';
|
||||
|
||||
describe('space_webhooks repository', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
let spaceId = '';
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-webhooks-'));
|
||||
repo = new Repository(join(tempDir, 'db.sqlite'));
|
||||
const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' });
|
||||
const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' });
|
||||
spaceId = space.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
const urlEnc = () => Buffer.from('fake-ciphertext-not-real-crypto');
|
||||
|
||||
it('creates a webhook and returns the full record (including url_enc as an opaque Buffer)', () => {
|
||||
const created = repo.createSpaceWebhook({
|
||||
spaceId,
|
||||
provider: 'discord',
|
||||
label: 'Dev channel',
|
||||
urlEnc: urlEnc(),
|
||||
events: ['succeeded', 'failed'],
|
||||
includeDetails: true,
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.spaceId).toBe(spaceId);
|
||||
expect(created.provider).toBe('discord');
|
||||
expect(created.label).toBe('Dev channel');
|
||||
expect(created.events).toEqual(['succeeded', 'failed']);
|
||||
expect(created.includeDetails).toBe(true);
|
||||
expect(created.enabled).toBe(true);
|
||||
expect(created.failureCount).toBe(0);
|
||||
expect(Buffer.isBuffer(created.urlEnc)).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults includeDetails to true when omitted, false when explicitly false', () => {
|
||||
const a = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'A', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
expect(a.includeDetails).toBe(true);
|
||||
const b = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'B', urlEnc: urlEnc(), events: ['succeeded'], includeDetails: false });
|
||||
expect(b.includeDetails).toBe(false);
|
||||
});
|
||||
|
||||
it('lists webhooks scoped to a space, ordered by creation', () => {
|
||||
repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'One', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Two', urlEnc: urlEnc(), events: ['failed'] });
|
||||
const list = repo.listSpaceWebhooks(spaceId);
|
||||
expect(list.map(w => w.label)).toEqual(['One', 'Two']);
|
||||
});
|
||||
|
||||
it('getSpaceWebhookById returns null for unknown id', () => {
|
||||
expect(repo.getSpaceWebhookById('does-not-exist')).toBeNull();
|
||||
});
|
||||
|
||||
it('updateSpaceWebhook patches label/events/includeDetails without touching unspecified fields', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Orig', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
const updated = repo.updateSpaceWebhook(created.id, { label: 'Renamed', events: ['failed', 'waiting_human'] });
|
||||
expect(updated?.label).toBe('Renamed');
|
||||
expect(updated?.events).toEqual(['failed', 'waiting_human']);
|
||||
expect(updated?.includeDetails).toBe(true); // untouched
|
||||
});
|
||||
|
||||
it('updateSpaceWebhook can replace url_enc (URL re-entry)', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Orig', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
const newEnc = Buffer.from('new-ciphertext');
|
||||
const updated = repo.updateSpaceWebhook(created.id, { urlEnc: newEnc });
|
||||
expect(updated?.urlEnc.equals(newEnc)).toBe(true);
|
||||
});
|
||||
|
||||
it('deleteSpaceWebhook removes the row', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Gone', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
repo.deleteSpaceWebhook(created.id);
|
||||
expect(repo.getSpaceWebhookById(created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('markSpaceWebhookSuccess resets failure_count and stamps last_success_at', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
repo.markSpaceWebhookFailure(created.id);
|
||||
repo.markSpaceWebhookFailure(created.id);
|
||||
repo.markSpaceWebhookSuccess(created.id);
|
||||
const after = repo.getSpaceWebhookById(created.id)!;
|
||||
expect(after.failureCount).toBe(0);
|
||||
expect(after.lastSuccessAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('markSpaceWebhookFailure increments failure_count and stamps last_failure_at', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
const result = repo.markSpaceWebhookFailure(created.id);
|
||||
expect(result.failureCount).toBe(1);
|
||||
expect(result.autoDisabled).toBe(false);
|
||||
const after = repo.getSpaceWebhookById(created.id)!;
|
||||
expect(after.failureCount).toBe(1);
|
||||
expect(after.lastFailureAt).toBeTruthy();
|
||||
expect(after.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-disables at the failure threshold (10 consecutive failures)', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
let result;
|
||||
for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) {
|
||||
result = repo.markSpaceWebhookFailure(created.id);
|
||||
}
|
||||
expect(result!.failureCount).toBe(AUTO_DISABLE_FAILURE_THRESHOLD);
|
||||
expect(result!.autoDisabled).toBe(true);
|
||||
const after = repo.getSpaceWebhookById(created.id)!;
|
||||
expect(after.enabled).toBe(false);
|
||||
expect(after.disabledReason).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not re-trigger autoDisabled=true on further failures once already disabled', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) repo.markSpaceWebhookFailure(created.id);
|
||||
const extra = repo.markSpaceWebhookFailure(created.id);
|
||||
expect(extra.autoDisabled).toBe(false); // already disabled; not a fresh transition
|
||||
expect(extra.failureCount).toBe(AUTO_DISABLE_FAILURE_THRESHOLD + 1);
|
||||
});
|
||||
|
||||
it('one success before the threshold prevents auto-disable', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD - 1; i++) repo.markSpaceWebhookFailure(created.id);
|
||||
repo.markSpaceWebhookSuccess(created.id);
|
||||
const after = repo.getSpaceWebhookById(created.id)!;
|
||||
expect(after.failureCount).toBe(0);
|
||||
expect(after.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('setSpaceWebhookEnabled(false) sets a manual disabled_reason', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
const updated = repo.setSpaceWebhookEnabled(created.id, false, 'manually paused');
|
||||
expect(updated?.enabled).toBe(false);
|
||||
expect(updated?.disabledReason).toBe('manually paused');
|
||||
});
|
||||
|
||||
it('setSpaceWebhookEnabled(true) re-enables, clears disabled_reason, and resets failure_count', () => {
|
||||
const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] });
|
||||
for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) repo.markSpaceWebhookFailure(created.id);
|
||||
expect(repo.getSpaceWebhookById(created.id)!.enabled).toBe(false);
|
||||
const reenabled = repo.setSpaceWebhookEnabled(created.id, true);
|
||||
expect(reenabled?.enabled).toBe(true);
|
||||
expect(reenabled?.disabledReason).toBeNull();
|
||||
expect(reenabled?.failureCount).toBe(0);
|
||||
});
|
||||
});
|
||||
253
src/db/repositories/webhooks.ts
Normal file
253
src/db/repositories/webhooks.ts
Normal file
@ -0,0 +1,253 @@
|
||||
// space_webhooks repository domain (issue #797, PR1). Follows the same
|
||||
// sub-repository + thin-delegation-on-Repository pattern as notifications.ts.
|
||||
// Facade: src/db/repository.ts (Repository class).
|
||||
//
|
||||
// SECURITY: url_enc is an AES-256-GCM envelope-encrypted BLOB (src/mcp/crypto.ts,
|
||||
// same scheme as mcp_servers.static_token_enc). This module never decrypts —
|
||||
// callers (webhook-service.ts) decrypt just-in-time for the outbound send and
|
||||
// never log or return the plaintext. rowToSpaceWebhook() keeps url_enc as an
|
||||
// opaque Buffer; the API layer (space-webhooks-api.ts) must never place it,
|
||||
// or a decrypted URL, into a JSON response.
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export type WebhookProvider = 'discord' | 'slack' | 'teams';
|
||||
export type WebhookEventType = 'succeeded' | 'failed' | 'waiting_human';
|
||||
|
||||
export const AUTO_DISABLE_FAILURE_THRESHOLD = 10;
|
||||
export const WARNING_FAILURE_THRESHOLD = 3;
|
||||
|
||||
export interface SpaceWebhookRecord {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
provider: WebhookProvider;
|
||||
label: string;
|
||||
urlEnc: Buffer;
|
||||
keyVersion: number;
|
||||
events: WebhookEventType[];
|
||||
includeDetails: boolean;
|
||||
enabled: boolean;
|
||||
disabledReason: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
failureCount: number;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSpaceWebhookInput {
|
||||
spaceId: string;
|
||||
provider: WebhookProvider;
|
||||
label: string;
|
||||
urlEnc: Buffer;
|
||||
keyVersion?: number;
|
||||
events: WebhookEventType[];
|
||||
includeDetails?: boolean;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateSpaceWebhookInput {
|
||||
label?: string;
|
||||
/** Present only when the caller re-entered a URL (URLs are write-only). */
|
||||
urlEnc?: Buffer;
|
||||
keyVersion?: number;
|
||||
events?: WebhookEventType[];
|
||||
includeDetails?: boolean;
|
||||
}
|
||||
|
||||
interface SpaceWebhookRow {
|
||||
id: string;
|
||||
space_id: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
url_enc: Buffer;
|
||||
key_version: number;
|
||||
events: string;
|
||||
include_details: number;
|
||||
enabled: number;
|
||||
disabled_reason: string | null;
|
||||
last_success_at: string | null;
|
||||
last_failure_at: string | null;
|
||||
failure_count: number;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function rowToSpaceWebhook(row: SpaceWebhookRow): SpaceWebhookRecord {
|
||||
let events: WebhookEventType[];
|
||||
try {
|
||||
const parsed = JSON.parse(row.events);
|
||||
events = Array.isArray(parsed) ? (parsed as WebhookEventType[]) : [];
|
||||
} catch {
|
||||
events = [];
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
spaceId: row.space_id,
|
||||
provider: row.provider as WebhookProvider,
|
||||
label: row.label,
|
||||
urlEnc: row.url_enc,
|
||||
keyVersion: row.key_version,
|
||||
events,
|
||||
includeDetails: row.include_details !== 0,
|
||||
enabled: row.enabled !== 0,
|
||||
disabledReason: row.disabled_reason,
|
||||
lastSuccessAt: row.last_success_at,
|
||||
lastFailureAt: row.last_failure_at,
|
||||
failureCount: row.failure_count,
|
||||
createdBy: row.created_by,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = `id, space_id, provider, label, url_enc, key_version, events,
|
||||
include_details, enabled, disabled_reason, last_success_at, last_failure_at,
|
||||
failure_count, created_by, created_at, updated_at`;
|
||||
|
||||
export function createSpaceWebhook(
|
||||
db: Database.Database,
|
||||
input: CreateSpaceWebhookInput,
|
||||
): SpaceWebhookRecord {
|
||||
const id = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO space_webhooks
|
||||
(id, space_id, provider, label, url_enc, key_version, events, include_details, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id,
|
||||
input.spaceId,
|
||||
input.provider,
|
||||
input.label,
|
||||
input.urlEnc,
|
||||
input.keyVersion ?? 1,
|
||||
JSON.stringify(input.events),
|
||||
input.includeDetails === false ? 0 : 1,
|
||||
input.createdBy ?? null,
|
||||
);
|
||||
return getSpaceWebhookById(db, id)!;
|
||||
}
|
||||
|
||||
export function listSpaceWebhooks(db: Database.Database, spaceId: string): SpaceWebhookRecord[] {
|
||||
const rows = db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM space_webhooks WHERE space_id = ? ORDER BY created_at ASC`)
|
||||
.all(spaceId) as SpaceWebhookRow[];
|
||||
return rows.map(rowToSpaceWebhook);
|
||||
}
|
||||
|
||||
export function getSpaceWebhookById(db: Database.Database, id: string): SpaceWebhookRecord | null {
|
||||
const row = db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM space_webhooks WHERE id = ?`)
|
||||
.get(id) as SpaceWebhookRow | undefined;
|
||||
return row ? rowToSpaceWebhook(row) : null;
|
||||
}
|
||||
|
||||
export function updateSpaceWebhook(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
patch: UpdateSpaceWebhookInput,
|
||||
): SpaceWebhookRecord | null {
|
||||
const sets: string[] = [];
|
||||
const params: Array<string | number | Buffer> = [];
|
||||
if (patch.label !== undefined) {
|
||||
sets.push('label = ?');
|
||||
params.push(patch.label);
|
||||
}
|
||||
if (patch.urlEnc !== undefined) {
|
||||
sets.push('url_enc = ?');
|
||||
params.push(patch.urlEnc);
|
||||
}
|
||||
if (patch.keyVersion !== undefined) {
|
||||
sets.push('key_version = ?');
|
||||
params.push(patch.keyVersion);
|
||||
}
|
||||
if (patch.events !== undefined) {
|
||||
sets.push('events = ?');
|
||||
params.push(JSON.stringify(patch.events));
|
||||
}
|
||||
if (patch.includeDetails !== undefined) {
|
||||
sets.push('include_details = ?');
|
||||
params.push(patch.includeDetails ? 1 : 0);
|
||||
}
|
||||
if (sets.length === 0) return getSpaceWebhookById(db, id);
|
||||
sets.push("updated_at = datetime('now')");
|
||||
params.push(id);
|
||||
db.prepare(`UPDATE space_webhooks SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
||||
return getSpaceWebhookById(db, id);
|
||||
}
|
||||
|
||||
export function deleteSpaceWebhook(db: Database.Database, id: string): void {
|
||||
db.prepare('DELETE FROM space_webhooks WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual enable/disable (settings-UI toggle and the post-auto-disable
|
||||
* "re-enable" action). Re-enabling also resets failure_count so the webhook
|
||||
* gets a fresh run at the failure thresholds, and clears disabled_reason.
|
||||
*/
|
||||
export function setSpaceWebhookEnabled(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
reason?: string | null,
|
||||
): SpaceWebhookRecord | null {
|
||||
if (enabled) {
|
||||
db.prepare(
|
||||
`UPDATE space_webhooks
|
||||
SET enabled = 1, disabled_reason = NULL, failure_count = 0, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(id);
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE space_webhooks
|
||||
SET enabled = 0, disabled_reason = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(reason ?? null, id);
|
||||
}
|
||||
return getSpaceWebhookById(db, id);
|
||||
}
|
||||
|
||||
/** Successful delivery: reset failure_count, stamp last_success_at. */
|
||||
export function markSpaceWebhookSuccess(db: Database.Database, id: string): void {
|
||||
db.prepare(
|
||||
`UPDATE space_webhooks
|
||||
SET last_success_at = datetime('now'), failure_count = 0, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed delivery: increment failure_count, stamp last_failure_at, and
|
||||
* auto-disable once failure_count reaches AUTO_DISABLE_FAILURE_THRESHOLD
|
||||
* (10 consecutive failures). Returns the post-update failure_count and
|
||||
* whether this call performed the auto-disable transition.
|
||||
*/
|
||||
export function markSpaceWebhookFailure(
|
||||
db: Database.Database,
|
||||
id: string,
|
||||
): { failureCount: number; autoDisabled: boolean } {
|
||||
db.prepare(
|
||||
`UPDATE space_webhooks
|
||||
SET last_failure_at = datetime('now'),
|
||||
failure_count = failure_count + 1,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(id);
|
||||
const row = db
|
||||
.prepare('SELECT failure_count, enabled FROM space_webhooks WHERE id = ?')
|
||||
.get(id) as { failure_count: number; enabled: number } | undefined;
|
||||
if (!row) return { failureCount: 0, autoDisabled: false };
|
||||
if (row.failure_count >= AUTO_DISABLE_FAILURE_THRESHOLD && row.enabled !== 0) {
|
||||
db.prepare(
|
||||
`UPDATE space_webhooks
|
||||
SET enabled = 0,
|
||||
disabled_reason = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(`${AUTO_DISABLE_FAILURE_THRESHOLD} consecutive delivery failures`, id);
|
||||
return { failureCount: row.failure_count, autoDisabled: true };
|
||||
}
|
||||
return { failureCount: row.failure_count, autoDisabled: false };
|
||||
}
|
||||
@ -39,6 +39,16 @@ describe('Repository calendar_events', () => {
|
||||
expect(got?.title).toBe('kickoff');
|
||||
});
|
||||
|
||||
it('claims a due reminder only once', async () => {
|
||||
const event = await repo.createCalendarEvent({
|
||||
spaceId: 'space-A', date: '2026-07-10', time: '10:00', title: 'remind', reminderMinutes: 10,
|
||||
});
|
||||
expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:49')).toEqual([]);
|
||||
const first = await repo.claimDueCalendarReminders('space-A', '2026-07-10 09:50');
|
||||
expect(first.map(item => item.id)).toEqual([event.id]);
|
||||
expect(await repo.claimDueCalendarReminders('space-A', '2026-07-10 10:00')).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults: createdBy=user, all-day (time null), description null', async () => {
|
||||
const ev = await repo.createCalendarEvent({ spaceId: 'space-A', date: '2026-06-20', title: 'allday' });
|
||||
expect(ev.createdBy).toBe('user');
|
||||
|
||||
61
src/db/repository.llm-selection.test.ts
Normal file
61
src/db/repository.llm-selection.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
|
||||
let tempDir = '';
|
||||
let dbPath = '';
|
||||
let repo: Repository;
|
||||
|
||||
async function seedTask(): Promise<number> {
|
||||
const created = await repo.createLocalTask({
|
||||
title: 'seed',
|
||||
body: 'do the thing',
|
||||
pieceName: 'auto',
|
||||
ownerId: 'owner-1',
|
||||
visibility: 'private',
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-llm-selection-'));
|
||||
dbPath = join(tempDir, 'db.sqlite');
|
||||
repo = new Repository(dbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('local_tasks.llm_worker_id / llm_effort (LLM selection Phase 1)', () => {
|
||||
it('is null on a freshly created task', async () => {
|
||||
const taskId = await seedTask();
|
||||
const task = await repo.getLocalTask(taskId);
|
||||
expect(task).toBeTruthy();
|
||||
expect(task!.llmWorkerId).toBeNull();
|
||||
expect(task!.llmEffort).toBeNull();
|
||||
});
|
||||
|
||||
it('updateLocalTask sets llmWorkerId/llmEffort and getLocalTask reads them back', async () => {
|
||||
const taskId = await seedTask();
|
||||
await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'medium' } as any);
|
||||
|
||||
const task = await repo.getLocalTask(taskId);
|
||||
expect(task).toBeTruthy();
|
||||
expect(task!.llmWorkerId).toBe('worker-gpu-1');
|
||||
expect(task!.llmEffort).toBe('medium');
|
||||
});
|
||||
|
||||
it('updateLocalTask can clear a pinned worker/effort back to null', async () => {
|
||||
const taskId = await seedTask();
|
||||
await repo.updateLocalTask(taskId, { llmWorkerId: 'worker-gpu-1', llmEffort: 'high' } as any);
|
||||
await repo.updateLocalTask(taskId, { llmWorkerId: null, llmEffort: null } as any);
|
||||
|
||||
const task = await repo.getLocalTask(taskId);
|
||||
expect(task!.llmWorkerId).toBeNull();
|
||||
expect(task!.llmEffort).toBeNull();
|
||||
});
|
||||
});
|
||||
92
src/db/repository.reminders.test.ts
Normal file
92
src/db/repository.reminders.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Repository } from './repository.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
describe('agent_reminders schema and repository', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'reminders-repo-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('Repository.initSchema creates agent_reminders with the expected columns', () => {
|
||||
const repo = new Repository(join(dir, 'fresh.db'));
|
||||
const cols = repo.getDb().prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>;
|
||||
expect(cols.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'owner_id',
|
||||
'space_id',
|
||||
'title',
|
||||
'body',
|
||||
'image_url',
|
||||
'due_at',
|
||||
'delivered_at',
|
||||
'cancelled_at',
|
||||
'created_at',
|
||||
]),
|
||||
);
|
||||
const indexes = repo.getDb().prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>;
|
||||
expect(indexes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('runMigrations creates agent_reminders on an upgrade path DB', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const cols = db.prepare("PRAGMA table_info('agent_reminders')").all() as Array<{ name: string }>;
|
||||
expect(cols.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'id',
|
||||
'owner_id',
|
||||
'space_id',
|
||||
'title',
|
||||
'body',
|
||||
'image_url',
|
||||
'due_at',
|
||||
'delivered_at',
|
||||
'cancelled_at',
|
||||
'created_at',
|
||||
]),
|
||||
);
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_agent_reminders_due'").all() as Array<{ name: string }>;
|
||||
expect(indexes).toHaveLength(1);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('stores, filters, and cancels reminders through the repository facade', () => {
|
||||
const repo = new Repository(join(dir, 'crud.db'));
|
||||
const reminder = repo.createAgentReminder({
|
||||
ownerId: 'u1',
|
||||
spaceId: 'space-a',
|
||||
title: '休憩',
|
||||
body: 'stretch',
|
||||
imageUrl: 'https://example.com/pin.png',
|
||||
dueAt: '2026-07-10T06:30:00.000Z',
|
||||
});
|
||||
|
||||
const listed = repo.listAgentReminders('u1', 'space-a');
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0]).toMatchObject({
|
||||
id: reminder.id,
|
||||
ownerId: 'u1',
|
||||
spaceId: 'space-a',
|
||||
title: '休憩',
|
||||
body: 'stretch',
|
||||
imageUrl: 'https://example.com/pin.png',
|
||||
dueAt: '2026-07-10T06:30:00.000Z',
|
||||
deliveredAt: null,
|
||||
cancelledAt: null,
|
||||
});
|
||||
|
||||
expect(repo.cancelAgentReminder(reminder.id, 'u1')).toBe(true);
|
||||
expect(repo.listAgentReminders('u1', 'space-a')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -4,7 +4,7 @@ import * as schemaRepo from './repositories/schema.js';
|
||||
import * as jobsRepo from './repositories/jobs.js';
|
||||
import { JobStatus, Job, CreateJobParams, SubtaskInfo } from './repositories/jobs.js';
|
||||
import * as spacesRepo from './repositories/spaces.js';
|
||||
import { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams } from './repositories/spaces.js';
|
||||
import { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams, SpaceDisplayPrefs, SpaceWithDisplayPrefs } from './repositories/spaces.js';
|
||||
import * as usersRepo from './repositories/users.js';
|
||||
import { User, CreateUserParams, FindOrCreateByOAuthParams, CreateLocalUserParams, LocalOrg, LocalOrgMember, GiteaOrgInput, GiteaOrg } from './repositories/users.js';
|
||||
import * as workerNodesRepo from './repositories/worker-nodes.js';
|
||||
@ -22,6 +22,10 @@ import * as llmUsageRepo from './repositories/llm-usage.js';
|
||||
import { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
|
||||
import * as notificationsRepo from './repositories/notifications.js';
|
||||
import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
||||
import * as webhooksRepo from './repositories/webhooks.js';
|
||||
import { SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
||||
import * as chatConnectorsRepo from './repositories/chat-connectors.js';
|
||||
import { ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js';
|
||||
import * as auditRepo from './repositories/audit.js';
|
||||
import * as appSharesRepo from './repositories/app-shares.js';
|
||||
import * as provenanceRepo from './repositories/provenance.js';
|
||||
@ -32,9 +36,14 @@ export type { TitleSource, LocalTask, MissionBriefField, MissionBrief, LocalTask
|
||||
import * as taskSearchRepo from './repositories/task-search.js';
|
||||
import * as calendarRepo from './repositories/calendar.js';
|
||||
import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
||||
import * as remindersRepo from './repositories/reminders.js';
|
||||
import { AgentReminder } from './repositories/reminders.js';
|
||||
export { enumerateLocalDays } from './repositories/calendar.js';
|
||||
export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js';
|
||||
export type { AgentReminder } from './repositories/reminders.js';
|
||||
export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js';
|
||||
export type { WebhookProvider, WebhookEventType, SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js';
|
||||
export type { ChatConnectorPlatform, ChatConnectorBindingStatus, ChatConnectorBindingRecord, CreateChatConnectorBindingInput, UpdateChatConnectorBindingInput } from './repositories/chat-connectors.js';
|
||||
export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
|
||||
export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js';
|
||||
export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js';
|
||||
@ -42,7 +51,7 @@ export type { A2aClientRow, A2aDelegationRow } from './repositories/a2a.js';
|
||||
export type { ScheduledTaskKind, ScheduledTask, CreateScheduledTaskParams, UpdateScheduledTaskParams } from './repositories/scheduled-tasks.js';
|
||||
export type { WorkerNode, UpsertWorkerNodeParams } from './repositories/worker-nodes.js';
|
||||
export type { User, CreateUserParams, FindOrCreateByOAuthParams, CreateLocalUserParams, LocalOrg, LocalOrgMember, GiteaOrgInput, GiteaOrg } from './repositories/users.js';
|
||||
export type { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams } from './repositories/spaces.js';
|
||||
export type { Space, SpaceMemberRoleValue, SpaceMember, SpaceInviteRole, SpaceInvite, CreateSpaceParams, SpaceDisplayPrefs, SpaceWithDisplayPrefs } from './repositories/spaces.js';
|
||||
export type { JobStatus, JobRole, JobProfile, TaskClass, Job, CreateJobParams, SubtaskInfo } from './repositories/jobs.js';
|
||||
export { localTaskRepoName } from './repositories/shared.js';
|
||||
|
||||
@ -111,6 +120,18 @@ export class Repository {
|
||||
return spacesRepo.listSpaces(this.db, opts);
|
||||
}
|
||||
|
||||
async listSpacesWithDisplayPrefs(opts: { viewer: Express.User; includeArchived?: boolean }): Promise<SpaceWithDisplayPrefs[]> {
|
||||
return spacesRepo.listSpacesWithDisplayPrefs(this.db, opts);
|
||||
}
|
||||
|
||||
getSpaceDisplayPrefs(userId: string, spaceId: string): SpaceDisplayPrefs {
|
||||
return spacesRepo.getSpaceDisplayPrefs(this.db, userId, spaceId);
|
||||
}
|
||||
|
||||
updateSpaceDisplayPrefs(userId: string, spaceId: string, patch: Partial<SpaceDisplayPrefs>): SpaceDisplayPrefs {
|
||||
return spacesRepo.updateSpaceDisplayPrefs(this.db, userId, spaceId, patch);
|
||||
}
|
||||
|
||||
async updateSpace(id: string, patch: { title?: string; description?: string; brandColor?: string | null; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null }): Promise<Space | null> {
|
||||
return spacesRepo.updateSpace(this.db, id, patch);
|
||||
}
|
||||
@ -600,6 +621,10 @@ export class Repository {
|
||||
return jobsRepo.updateJobsVisibilityForTask(this.db, taskId, updates);
|
||||
}
|
||||
|
||||
async updateJobsLlmSelectionForTask(taskId: number, sel: { workerId: string | null; effort: string | null }): Promise<void> {
|
||||
return jobsRepo.updateJobsLlmSelectionForTask(this.db, taskId, sel);
|
||||
}
|
||||
|
||||
async getSubJobs(parentJobId: string): Promise<Job[]> {
|
||||
return jobsRepo.getSubJobs(this.db, parentJobId);
|
||||
}
|
||||
@ -827,6 +852,7 @@ export class Repository {
|
||||
endDate?: string | null;
|
||||
time?: string | null;
|
||||
endTime?: string | null;
|
||||
reminderMinutes?: number | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
createdBy?: 'user' | 'agent';
|
||||
@ -917,7 +943,7 @@ export class Repository {
|
||||
return calendarRepo.getCalendarEvent(this.db, eventId);
|
||||
}
|
||||
|
||||
async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||
async updateCalendarEvent(eventId: number, fields: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null }): Promise<CalendarEvent | null> {
|
||||
return calendarRepo.updateCalendarEvent(this.db, eventId, fields);
|
||||
}
|
||||
|
||||
@ -925,6 +951,22 @@ export class Repository {
|
||||
return calendarRepo.deleteCalendarEvent(this.db, eventId);
|
||||
}
|
||||
|
||||
createAgentReminder(input: { ownerId?: string | null; spaceId?: string | null; title?: string | null; body: string; imageUrl?: string | null; dueAt: string }): AgentReminder {
|
||||
return remindersRepo.createAgentReminder(this.db, input);
|
||||
}
|
||||
|
||||
listAgentReminders(ownerId: string | null | undefined, spaceId?: string | null): AgentReminder[] {
|
||||
return remindersRepo.listAgentReminders(this.db, ownerId, spaceId);
|
||||
}
|
||||
|
||||
cancelAgentReminder(id: string, ownerId: string | null | undefined): boolean {
|
||||
return remindersRepo.cancelAgentReminder(this.db, id, ownerId);
|
||||
}
|
||||
|
||||
async claimDueCalendarReminders(spaceId: string, nowLocal: string): Promise<CalendarEvent[]> {
|
||||
return calendarRepo.claimDueCalendarReminders(this.db, spaceId, nowLocal);
|
||||
}
|
||||
|
||||
private calendarTaskScope(spaceId: string, personalOwnerId?: string | null, alias = ''): { clause: string; params: unknown[] } {
|
||||
return calendarRepo.calendarTaskScope(spaceId, personalOwnerId, alias);
|
||||
}
|
||||
@ -1082,6 +1124,76 @@ export class Repository {
|
||||
return notificationsRepo.markV1MigrationComplete(this.db, userId);
|
||||
}
|
||||
|
||||
// ── Workspace webhooks (issue #797, PR1) ──────────────────────────────
|
||||
createSpaceWebhook(input: CreateSpaceWebhookInput): SpaceWebhookRecord {
|
||||
return webhooksRepo.createSpaceWebhook(this.db, input);
|
||||
}
|
||||
|
||||
listSpaceWebhooks(spaceId: string): SpaceWebhookRecord[] {
|
||||
return webhooksRepo.listSpaceWebhooks(this.db, spaceId);
|
||||
}
|
||||
|
||||
getSpaceWebhookById(id: string): SpaceWebhookRecord | null {
|
||||
return webhooksRepo.getSpaceWebhookById(this.db, id);
|
||||
}
|
||||
|
||||
updateSpaceWebhook(id: string, patch: UpdateSpaceWebhookInput): SpaceWebhookRecord | null {
|
||||
return webhooksRepo.updateSpaceWebhook(this.db, id, patch);
|
||||
}
|
||||
|
||||
deleteSpaceWebhook(id: string): void {
|
||||
return webhooksRepo.deleteSpaceWebhook(this.db, id);
|
||||
}
|
||||
|
||||
setSpaceWebhookEnabled(id: string, enabled: boolean, reason?: string | null): SpaceWebhookRecord | null {
|
||||
return webhooksRepo.setSpaceWebhookEnabled(this.db, id, enabled, reason);
|
||||
}
|
||||
|
||||
markSpaceWebhookSuccess(id: string): void {
|
||||
return webhooksRepo.markSpaceWebhookSuccess(this.db, id);
|
||||
}
|
||||
|
||||
markSpaceWebhookFailure(id: string): { failureCount: number; autoDisabled: boolean } {
|
||||
return webhooksRepo.markSpaceWebhookFailure(this.db, id);
|
||||
}
|
||||
|
||||
// ── Chat connector bindings (issue #801, PR1 — Slack MVP) ──────────────
|
||||
createChatConnectorBinding(input: CreateChatConnectorBindingInput): ChatConnectorBindingRecord {
|
||||
return chatConnectorsRepo.createChatConnectorBinding(this.db, input);
|
||||
}
|
||||
|
||||
listChatConnectorBindings(): ChatConnectorBindingRecord[] {
|
||||
return chatConnectorsRepo.listChatConnectorBindings(this.db);
|
||||
}
|
||||
|
||||
getChatConnectorBindingById(id: string): ChatConnectorBindingRecord | null {
|
||||
return chatConnectorsRepo.getChatConnectorBindingById(this.db, id);
|
||||
}
|
||||
|
||||
findActiveChatConnectorBinding(
|
||||
platform: chatConnectorsRepo.ChatConnectorPlatform,
|
||||
externalWorkspaceId: string,
|
||||
externalChannelId: string,
|
||||
): ChatConnectorBindingRecord | null {
|
||||
return chatConnectorsRepo.findActiveChatConnectorBinding(this.db, platform, externalWorkspaceId, externalChannelId);
|
||||
}
|
||||
|
||||
listChatConnectorBindingsForDelegation(a2aDelegationId: string): ChatConnectorBindingRecord[] {
|
||||
return chatConnectorsRepo.listChatConnectorBindingsForDelegation(this.db, a2aDelegationId);
|
||||
}
|
||||
|
||||
updateChatConnectorBinding(id: string, patch: UpdateChatConnectorBindingInput): ChatConnectorBindingRecord | null {
|
||||
return chatConnectorsRepo.updateChatConnectorBinding(this.db, id, patch);
|
||||
}
|
||||
|
||||
deleteChatConnectorBinding(id: string): void {
|
||||
return chatConnectorsRepo.deleteChatConnectorBinding(this.db, id);
|
||||
}
|
||||
|
||||
claimChatConnectorEvent(eventId: string): boolean {
|
||||
return chatConnectorsRepo.claimChatConnectorEvent(this.db, eventId);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
@ -34,6 +34,10 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
space_id TEXT,
|
||||
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
||||
runtime_dir TEXT,
|
||||
-- LLM 選択 Phase 1: per-job worker/effort pin。NULL = 従来の profile ルーティング。
|
||||
-- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||
required_worker_id TEXT,
|
||||
reasoning_effort TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@ -82,7 +86,13 @@ CREATE TABLE IF NOT EXISTS local_tasks (
|
||||
-- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)(計画3)
|
||||
workspace_mode TEXT NOT NULL DEFAULT 'persistent',
|
||||
-- 実行ログ root(計画5)。NULL = 後方互換で workspace_path/logs に解決。
|
||||
runtime_dir TEXT
|
||||
runtime_dir TEXT,
|
||||
-- LLM 選択 Phase 1: タスク単位のスティッキー選択。follow-up コメントで
|
||||
-- 生成される後続 job にも引き継がれる(引き継ぎ配線は後続タスク)。
|
||||
-- NULL = 未選択(従来の profile ルーティング)。
|
||||
-- spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
|
||||
llm_worker_id TEXT,
|
||||
llm_effort TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
||||
@ -112,6 +122,20 @@ CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal';
|
||||
|
||||
-- ─── スペース表示設定(ユーザー別)────────────────────────────────
|
||||
-- お気に入り・非表示は権限や共有状態ではなく、閲覧者ごとの一覧整理だけに使う。
|
||||
CREATE TABLE IF NOT EXISTS space_user_prefs (
|
||||
user_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL,
|
||||
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0,1)),
|
||||
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0,1)),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, space_id),
|
||||
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_user ON space_user_prefs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_space ON space_user_prefs(space_id);
|
||||
|
||||
-- ─── スペース・メンバー(協働者)─────────────────────────────────
|
||||
-- owner_id 以外の追加協働者。owner は members 行を持たず owner_id で判定する。
|
||||
-- 可視性は buildVisibilityWhere(spaceColumn) の OR ブランチで membership を通す。
|
||||
@ -142,6 +166,8 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
end_date TEXT, -- 終了日 YYYY-MM-DD。NULL = date と同じ(単日)
|
||||
time TEXT, -- 開始 HH:MM。NULL = 終日
|
||||
end_time TEXT, -- 終了 HH:MM。NULL = 終了時刻なし(time が NULL なら常に NULL)
|
||||
reminder_minutes INTEGER, -- 開始何分前に通知するか。NULL = 通知なし
|
||||
reminder_delivered_at TEXT, -- 配信済み UTC。予定/通知設定変更時に NULL へ戻す
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by TEXT NOT NULL DEFAULT 'user', -- 'user' / 'agent'
|
||||
@ -152,6 +178,20 @@ CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_reminders (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT,
|
||||
space_id TEXT,
|
||||
title TEXT,
|
||||
body TEXT NOT NULL,
|
||||
image_url TEXT,
|
||||
due_at TEXT NOT NULL,
|
||||
delivered_at TEXT,
|
||||
cancelled_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
|
||||
|
||||
-- ─── スペース招待リンク(再利用トークン)─────────────────────────────
|
||||
-- 組織非依存の招待経路。canManageSpace が発行し、リンクを知る相手が参加する。
|
||||
-- スペースごとに有効リンクは最大1本(再生成で旧 invite を削除して回す)。
|
||||
@ -892,6 +932,73 @@ CREATE TABLE IF NOT EXISTS user_notification_prefs (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Workspace-level webhook notifications (issue #797, PR1) ──────────────
|
||||
-- Discord/Slack/Teams incoming-webhook notifications for a space. url_enc is
|
||||
-- the AES-256-GCM envelope-encrypted webhook URL (src/mcp/crypto.ts, same
|
||||
-- scheme as mcp_servers.static_token_enc) — the plaintext URL is NEVER
|
||||
-- stored, logged, or returned by the API. key_version supports future key
|
||||
-- rotation. events is a JSON array of NotifyEventType strings, e.g.
|
||||
-- '["succeeded","failed","waiting_human"]'. 10 consecutive failures
|
||||
-- auto-disables the webhook (enabled=0, disabled_reason set); the UI offers
|
||||
-- a re-enable action.
|
||||
CREATE TABLE IF NOT EXISTS space_webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL DEFAULT 'discord', -- 'discord' | 'slack' | 'teams' (only discord in PR1)
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
url_enc BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
events TEXT NOT NULL DEFAULT '["succeeded","failed","waiting_human"]',
|
||||
include_details INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
disabled_reason TEXT,
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id);
|
||||
|
||||
-- Chat connector bindings (issue #801, PR1 — Slack MVP): links an external chat
|
||||
-- channel to a MAESTRO space + an existing A2A delegation. The chat-connector
|
||||
-- service reuses the delegation's AND-intersected scope (space/skills) and the
|
||||
-- A2A governance limiter — no separate authz path. bot_credentials_enc is an
|
||||
-- AES-256-GCM envelope-encrypted BLOB (src/mcp/crypto.ts, same scheme as
|
||||
-- space_webhooks.url_enc) holding the Slack signing secret + bot token JSON.
|
||||
-- Never decrypted in this table's repo module; only chat-connector-service.ts
|
||||
-- decrypts just-in-time and never logs/returns the plaintext.
|
||||
CREATE TABLE IF NOT EXISTS chat_connector_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL DEFAULT 'slack', -- 'slack' (PR1 only)
|
||||
external_workspace_id TEXT NOT NULL, -- Slack team id
|
||||
external_channel_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
a2a_client_id TEXT NOT NULL, -- a2a_clients.client_id
|
||||
a2a_delegation_id TEXT NOT NULL, -- a2a_delegations.id
|
||||
a2a_grant_id TEXT NOT NULL, -- a2a_delegations.grant_id (snapshot)
|
||||
bot_credentials_enc BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'disabled'
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel
|
||||
ON chat_connector_bindings (platform, external_workspace_id, external_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id);
|
||||
|
||||
-- Slack can retry a successfully delivered Events API envelope. Claiming its
|
||||
-- event_id before dispatch keeps task creation and replies exactly-once.
|
||||
CREATE TABLE IF NOT EXISTS chat_connector_processed_events (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at
|
||||
ON chat_connector_processed_events (received_at);
|
||||
|
||||
-- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created
|
||||
-- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts
|
||||
-- (upgrade path) so the task-list display SELECT never hits "no such table".
|
||||
|
||||
@ -2179,8 +2179,13 @@ describe('buildGlobalPreamble / buildMovementGuidance split (Phase A)', () => {
|
||||
|
||||
it('preamble is byte-identical across two different movements (job-stable)', () => {
|
||||
const tools = [toolDef('Read'), toolDef('Bash')];
|
||||
const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }) });
|
||||
const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }) });
|
||||
// The clock is an explicit input (現在日時 section); pin it so the assertion
|
||||
// isolates the actual contract — the preamble must not depend on the movement.
|
||||
// At runtime the preamble is built once per conversation seed, so within a
|
||||
// job the clock is naturally fixed.
|
||||
const now = new Date(2026, 6, 8, 12, 0);
|
||||
const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }), now });
|
||||
const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }), now });
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
|
||||
@ -83,7 +83,11 @@ export async function runDelegateSubAgent(
|
||||
const subCache = new ToolResultCache();
|
||||
const subEvents = parentCtx.eventLogger?.child({
|
||||
movement: `delegate:${params.description.slice(0, 40)}`,
|
||||
// delegateRunId: 実行帰属タグ。tool_call が correlationId を上書きしても
|
||||
// 保持されるので、reconstructDelegateRuns がツールイベントを確実に束ねられる。
|
||||
// correlationId: 後方互換(correlationId 上書きしないイベントの既定値)。
|
||||
correlationId: delegateRunId,
|
||||
delegateRunId,
|
||||
});
|
||||
|
||||
const subCtx: ToolContext = {
|
||||
|
||||
@ -152,3 +152,28 @@ describe('buildGlobalPreamble — SSH console screen scoped to allowed connectio
|
||||
expect(out).toContain('connOnly-line-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlobalPreamble — current datetime injection', () => {
|
||||
it('renders the given clock as a 現在日時 section with weekday, so agents can name dated files', () => {
|
||||
// Local-time constructor: 2026-07-08 is a Wednesday regardless of server TZ.
|
||||
const out = buildGlobalPreamble([], { now: new Date(2026, 6, 8, 9, 30) });
|
||||
expect(out).toContain('## 現在日時');
|
||||
// The rendered clock is server-local, so the timezone offset must be explicit
|
||||
// (e.g. "2026-07-08 (水) 09:30 (UTC+9)") to disambiguate for users elsewhere.
|
||||
expect(out).toMatch(/2026-07-08 \(水\) 09:30 \(UTC[+-]\d{1,2}(?::\d{2})?\)/);
|
||||
// Guidance so pieces can rely on it for dated output filenames.
|
||||
expect(out).toContain('report-2026-07-08.md');
|
||||
});
|
||||
|
||||
it('falls back to the real clock when now is not passed', () => {
|
||||
const fmt = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
// Sample the clock on both sides of the call so a midnight rollover
|
||||
// between the internal new Date() and ours cannot flake the test.
|
||||
const before = new Date();
|
||||
const out = buildGlobalPreamble([]);
|
||||
const after = new Date();
|
||||
expect(out).toContain('## 現在日時');
|
||||
expect(out.includes(fmt(before)) || out.includes(fmt(after))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,6 +176,25 @@ export interface GlobalPreambleOpts {
|
||||
skillIndex?: string;
|
||||
folderContext?: { rootDir: string; leafId: string };
|
||||
movementForConsole?: ConsoleMovement;
|
||||
/** テスト用の時刻注入。未指定なら実時刻 */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
function formatPromptDatetime(d: Date): { full: string; date: string } {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
// Server-local clock: make the zone explicit so readers in other timezones
|
||||
// (and the LLM) know which zone the date belongs to.
|
||||
const offsetMin = -d.getTimezoneOffset();
|
||||
const sign = offsetMin >= 0 ? '+' : '-';
|
||||
const absMin = Math.abs(offsetMin);
|
||||
const tz = `UTC${sign}${Math.floor(absMin / 60)}${absMin % 60 ? `:${pad(absMin % 60)}` : ''}`;
|
||||
return {
|
||||
date,
|
||||
full: `${date} (${WEEKDAYS_JA[d.getDay()]}) ${pad(d.getHours())}:${pad(d.getMinutes())} (${tz})`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleOpts = {}): string {
|
||||
@ -191,6 +210,11 @@ export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleO
|
||||
movementForConsole,
|
||||
} = opts;
|
||||
|
||||
const clock = formatPromptDatetime(opts.now ?? new Date());
|
||||
const datetimeSection = `## 現在日時
|
||||
${clock.full}
|
||||
- 「今日」「最新」等の相対表現や、レポート・ファイル名に入れる日付はこの日時を基準にすること (例: \`output/report-${clock.date}.md\`)`;
|
||||
|
||||
const missionBlock = renderMissionBrief(missionBrief);
|
||||
const hasBash = tools.some((t) => t.function.name === 'Bash');
|
||||
const scriptGuidanceSection = hasBash
|
||||
@ -290,7 +314,9 @@ workspace の input/ output/ logs/ には前 piece の成果物が残ってい
|
||||
新規作業を始める前に Glob / Read で既存ファイルを確認してください。`;
|
||||
}
|
||||
|
||||
const preamble = `${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection}
|
||||
const preamble = `${datetimeSection}
|
||||
|
||||
${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection}
|
||||
|
||||
## アプローチの考え方 (全タスク共通)
|
||||
- 依頼に着手する前に、想定アプローチを **2-3 個** 浮かべて比較してから動く。最初に思いついた手段で即着手しないこと
|
||||
|
||||
@ -38,6 +38,7 @@ vi.mock('./tools/index.js', () => ({
|
||||
// Import after mocking
|
||||
import { runPiece } from './piece-runner.js';
|
||||
import { Conversation } from './context/conversation.js';
|
||||
import { reconstructDelegateRuns } from '../progress/delegate-runs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unique sentinel strings — must survive the full test without false positives
|
||||
@ -454,15 +455,100 @@ describe('runPiece — delegate E2E (Phase B Task 4)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((complete as any).payload.status).toBe('success');
|
||||
|
||||
// Sub-internal events (e.g. tool_call from Write) must share correlationId === delegateRunId
|
||||
// Sub-internal events must carry the delegateRunId attribution tag.
|
||||
// NOTE: correlationId は tool_call が per-tool UUID で上書きするため
|
||||
// 帰属キーには使えない。delegateRunId フィールドで束ねるのが正。
|
||||
const subEvent = events.find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(e: any) =>
|
||||
e.correlationId === runId &&
|
||||
e.delegateRunId === runId &&
|
||||
e.kind !== 'delegate_start' &&
|
||||
e.kind !== 'delegate_complete',
|
||||
);
|
||||
expect(subEvent, 'at least one sub-internal event must carry correlationId === delegateRunId').toBeTruthy();
|
||||
expect(subEvent, 'at least one sub-internal event must carry delegateRunId === runId').toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'回帰: sub-agent の通常ツール呼び出しが reconstructDelegateRuns で toolCalls に数えられる',
|
||||
async () => {
|
||||
// 「ツール 0 回」バグの真のエンドツーエンド再発防止。
|
||||
// sub-agent が通常ツール(ReadThing)を呼ぶと tool-execution が
|
||||
// per-tool correlationId を発行して上書きする。それでも scope の
|
||||
// delegateRunId で束ねられ、toolCalls が加算されねばならない。
|
||||
const tmp = makeTmpWorkspace();
|
||||
workspacePath = tmp.workspacePath;
|
||||
const { logsRoot } = tmp;
|
||||
|
||||
getToolDefsMock.mockResolvedValue(makeToolDefs(['delegate', 'ReadThing']));
|
||||
|
||||
executeToolMock.mockImplementation(
|
||||
async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
|
||||
if (name === 'delegate') {
|
||||
if (!ctx.runDelegate) {
|
||||
return { output: 'runDelegate not injected', isError: true };
|
||||
}
|
||||
const { result, status } = await ctx.runDelegate({
|
||||
description: String(input['description'] ?? 'sub-task'),
|
||||
prompt: String(input['prompt'] ?? ''),
|
||||
});
|
||||
if (status === 'aborted') {
|
||||
return { output: `[delegate aborted] ${result}`, isError: true };
|
||||
}
|
||||
return { output: result, isError: false };
|
||||
}
|
||||
return { output: 'ok', isError: false };
|
||||
},
|
||||
);
|
||||
|
||||
const client = new SpyFakeClient([
|
||||
// parent turn 1 — delegate
|
||||
[
|
||||
{ type: 'tool_use', id: 'del-tc-1', name: 'delegate', input: { description: 'read a thing', prompt: SUB_PROMPT_MARKER } },
|
||||
{ type: 'done', usage: { prompt_tokens: 100, completion_tokens: 10 } },
|
||||
],
|
||||
// sub turn 1 — regular tool call (ReadThing)
|
||||
[
|
||||
{ type: 'tool_use', id: 'sub-read-1', name: 'ReadThing', input: { file_path: 'x.txt' } },
|
||||
{ type: 'done', usage: { prompt_tokens: 80, completion_tokens: 5 } },
|
||||
],
|
||||
// sub turn 2 — complete
|
||||
[
|
||||
{ type: 'tool_use', id: 'sub-complete-1', name: 'complete', input: { status: 'success', result: SUB_RESULT_MARKER } },
|
||||
{ type: 'done', usage: { prompt_tokens: 60, completion_tokens: 5 } },
|
||||
],
|
||||
// parent turn 2 — complete
|
||||
[
|
||||
{ type: 'tool_use', id: 'par-complete-1', name: 'complete', input: { status: 'success', result: `parent: ${SUB_RESULT_MARKER}` } },
|
||||
{ type: 'done', usage: { prompt_tokens: 120, completion_tokens: 10 } },
|
||||
],
|
||||
]);
|
||||
|
||||
const piece = delegatePiece();
|
||||
await runPiece(
|
||||
piece,
|
||||
PARENT_TASK_INSTRUCTION,
|
||||
client as never,
|
||||
workspacePath,
|
||||
undefined,
|
||||
undefined,
|
||||
{ runtimeDir: logsRoot, workspaceTools: { allowedTools: ['delegate', 'ReadThing'], editAllowed: true } },
|
||||
);
|
||||
|
||||
const { parseEventLine: pel } = await import('../progress/event-log.js');
|
||||
const raw = readFileSync(join(logsRoot, 'events.jsonl'), 'utf-8');
|
||||
const events = raw
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => pel(l))
|
||||
.filter((r) => r.kind === 'ok')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((r: any) => r.event);
|
||||
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs, 'exactly one delegate run reconstructed').toHaveLength(1);
|
||||
expect(runs[0].toolCalls, 'ReadThing tool_call must be counted (not 0)').toBe(1);
|
||||
expect(runs[0].toolSummary).toEqual([{ tool: 'ReadThing', count: 1 }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -19,6 +19,8 @@ export interface ReflectionLlmConfig {
|
||||
* client's conservative 32k default, which would block valid prompts.
|
||||
*/
|
||||
contextLimitTokens?: number;
|
||||
/** Worker の extraBody(予約キー除去つきで OpenAI 互換 request body へ浅いマージ)。 */
|
||||
extraBody?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReflectionLlmResult {
|
||||
@ -123,7 +125,7 @@ async function callOnce(
|
||||
cfg.contextLimitTokens, // real model window; avoid the 32k default blocking large reflection prompts
|
||||
undefined,
|
||||
undefined,
|
||||
{ proxy: cfg.proxy === true },
|
||||
{ proxy: cfg.proxy === true, extraBody: cfg.extraBody },
|
||||
);
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
|
||||
@ -29,6 +29,8 @@ export interface RunReflectionDeps {
|
||||
llmProxy?: boolean;
|
||||
/** Reflection worker's model context window (tokens) for the prompt guard. */
|
||||
llmContextLimitTokens?: number;
|
||||
/** Reflection worker's extraBody(同じ worker def から渡す浅いマージ用 JSON)。 */
|
||||
llmExtraBody?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function runReflectionJob(
|
||||
@ -118,6 +120,7 @@ export async function runReflectionJob(
|
||||
proxy: deps.llmProxy === true,
|
||||
userId: meta.userId,
|
||||
contextLimitTokens: deps.llmContextLimitTokens,
|
||||
extraBody: deps.llmExtraBody,
|
||||
};
|
||||
|
||||
let llmResult;
|
||||
|
||||
@ -18,12 +18,16 @@ describe('sns-deep-sweep piece', () => {
|
||||
expect(mv.default_next).toBe('COMPLETE');
|
||||
});
|
||||
|
||||
it('instruction guides one-delegate-per-tweet and the deepdive/report file layout', () => {
|
||||
it('instruction guides one-delegate-per-tweet and the dated deepdive/report file layout', () => {
|
||||
const piece = loadPiece('sns-deep-sweep');
|
||||
const instr = piece.movements[0]!.instruction;
|
||||
expect(instr).toContain('output/deepdive/');
|
||||
expect(instr).toContain('output/report.md');
|
||||
expect(instr).toContain('output/triage.md');
|
||||
// Outputs are date-scoped so repeated runs in a persistent space accumulate
|
||||
// instead of overwriting the previous run's files.
|
||||
expect(instr).toContain('output/deepdive/{DATE}/');
|
||||
expect(instr).toContain('output/report-{DATE}.md');
|
||||
expect(instr).toContain('output/triage-{DATE}.md');
|
||||
expect(instr).toContain('現在日時');
|
||||
expect(instr).not.toContain('output/report.md ');
|
||||
// The sub must be told not to delegate further (leaf guard via prompt).
|
||||
expect(instr).toContain('delegate は呼ぶな');
|
||||
});
|
||||
|
||||
@ -34,6 +34,36 @@ describe('calendar tools', () => {
|
||||
it('exposes TOOL_DEFS entries', () => {
|
||||
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
|
||||
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
|
||||
expect(TOOL_DEFS.CreateReminder?.function.name).toBe('CreateReminder');
|
||||
expect(TOOL_DEFS.ListReminders?.function.name).toBe('ListReminders');
|
||||
expect(TOOL_DEFS.CancelReminder?.function.name).toBe('CancelReminder');
|
||||
});
|
||||
|
||||
it('creates, lists, and cancels an owner-scoped relative reminder', async () => {
|
||||
const created = await executeTool('CreateReminder', { body: '休憩する', delay_minutes: 20, title: '休憩' }, ctx());
|
||||
expect(created?.isError).toBe(false);
|
||||
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||
expect((await executeTool('CancelReminder', { id }, ctx()))?.isError).toBe(false);
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain('ありません');
|
||||
});
|
||||
|
||||
it('rejects past or ambiguous reminder times', async () => {
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: 'not-a-time' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-02-30T09:00:00Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T24:00Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00+23:59' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2020-01-01T00:00:00.000Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: new Date(Date.now() + 60_000).toISOString(), delay_minutes: 1 }, ctx()))?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('does not expose or cancel another owner\'s reminder', async () => {
|
||||
const created = await executeTool('CreateReminder', { body: 'private', delay_minutes: 10 }, ctx());
|
||||
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||
expect((await executeTool('ListReminders', {}, ctx({ ownerId: 'u2' })))?.output).toContain('ありません');
|
||||
expect((await executeTool('CancelReminder', { id }, ctx({ ownerId: 'u2' })))?.isError).toBe(true);
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => {
|
||||
|
||||
@ -6,6 +6,14 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const MAX_TITLE_LEN = 200;
|
||||
const MAX_DESC_LEN = 4000;
|
||||
const MAX_REMINDER_BODY_LEN = 4000;
|
||||
const ISO_WITH_TIMEZONE_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))$/;
|
||||
|
||||
function isValidIsoWithTimezone(value: string): boolean {
|
||||
if (!ISO_WITH_TIMEZONE_PATTERN.test(value) || Number.isNaN(Date.parse(value))) return false;
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
||||
return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
let _repo: Repository | null = null;
|
||||
|
||||
@ -51,9 +59,29 @@ const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_REMINDER_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'CreateReminder',
|
||||
description: '指定時刻または指定分後に、実行ユーザー向けの通知を登録する。過去時刻や曖昧な時刻は登録しない。',
|
||||
parameters: { type: 'object', properties: {
|
||||
body: { type: 'string', description: '通知本文(必須)' },
|
||||
due_at: { type: 'string', description: 'UTC ISO 8601 時刻。delay_minutes と排他' },
|
||||
delay_minutes: { type: 'number', description: '現在からの分数。due_at と排他、1以上' },
|
||||
title: { type: 'string', description: '任意のタイトル' }, image_url: { type: 'string', description: '任意の HTTPS 画像 URL' },
|
||||
}, required: ['body'] },
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_REMINDERS_DEF: ToolDef = { type: 'function', function: { name: 'ListReminders', description: '実行ユーザーが未配信かつ取消していないリマインダーを一覧する。', parameters: { type: 'object', properties: {}, required: [] } } };
|
||||
const CANCEL_REMINDER_DEF: ToolDef = { type: 'function', function: { name: 'CancelReminder', description: '実行ユーザーの未配信リマインダーを ID で取消する。', parameters: { type: 'object', properties: { id: { type: 'string', description: 'ListReminders で確認した ID' } }, required: ['id'] } } };
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
||||
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
||||
CreateReminder: CREATE_REMINDER_DEF,
|
||||
ListReminders: LIST_REMINDERS_DEF,
|
||||
CancelReminder: CANCEL_REMINDER_DEF,
|
||||
};
|
||||
|
||||
export async function executeTool(
|
||||
@ -63,9 +91,57 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
||||
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
||||
if (name === 'CreateReminder') return executeCreateReminder(input, ctx);
|
||||
if (name === 'ListReminders') return executeListReminders(ctx);
|
||||
if (name === 'CancelReminder') return executeCancelReminder(input, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireReminderOwner(ctx: ToolContext): string | null {
|
||||
return typeof ctx.ownerId === 'string' && ctx.ownerId.length > 0 ? ctx.ownerId : null;
|
||||
}
|
||||
|
||||
async function executeCreateReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||
const body = input.body;
|
||||
if (typeof body !== 'string' || !body.trim() || body.length > MAX_REMINDER_BODY_LEN) return { output: `body は1〜${MAX_REMINDER_BODY_LEN}文字で指定してください`, isError: true };
|
||||
const title = input.title;
|
||||
if (title !== undefined && (typeof title !== 'string' || title.length > MAX_TITLE_LEN)) return { output: `title は最大${MAX_TITLE_LEN}文字です`, isError: true };
|
||||
const imageUrl = input.image_url;
|
||||
if (imageUrl !== undefined && (typeof imageUrl !== 'string' || !imageUrl.startsWith('https://'))) return { output: 'image_url は HTTPS URL で指定してください', isError: true };
|
||||
const hasDueAt = input.due_at !== undefined;
|
||||
const hasDelay = input.delay_minutes !== undefined;
|
||||
if (hasDueAt === hasDelay) return { output: 'due_at または delay_minutes のどちらか一方を指定してください', isError: true };
|
||||
let due: Date;
|
||||
if (hasDueAt) {
|
||||
if (typeof input.due_at !== 'string' || !isValidIsoWithTimezone(input.due_at)) return { output: 'due_at は実在する、タイムゾーン付き ISO 8601 形式で指定してください', isError: true };
|
||||
due = new Date(input.due_at);
|
||||
} else {
|
||||
if (typeof input.delay_minutes !== 'number' || !Number.isFinite(input.delay_minutes) || input.delay_minutes < 1) return { output: 'delay_minutes は1以上の分数で指定してください', isError: true };
|
||||
due = new Date(Date.now() + input.delay_minutes * 60_000);
|
||||
}
|
||||
if (due.getTime() <= Date.now()) return { output: '過去の時刻には登録できません。明示的な未来時刻を指定してください', isError: true };
|
||||
const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString() });
|
||||
return { output: `リマインダーを登録しました: ${reminder.dueAt} (id=${reminder.id})`, isError: false };
|
||||
}
|
||||
|
||||
async function executeListReminders(ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||
const reminders = _repo.listAgentReminders(ownerId);
|
||||
return { output: reminders.length ? reminders.map(r => `- ${r.dueAt} 「${r.title ?? r.body}」(id=${r.id})`).join('\n') : '未配信のリマインダーはありません', isError: false };
|
||||
}
|
||||
|
||||
async function executeCancelReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId || typeof input.id !== 'string' || !input.id) return { output: 'id とユーザー所有者が必要です', isError: true };
|
||||
return _repo.cancelAgentReminder(input.id, ownerId) ? { output: 'リマインダーを取消しました', isError: false } : { output: '未配信のリマインダーが見つかりません', isError: true };
|
||||
}
|
||||
|
||||
async function executeAddCalendarEvent(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
|
||||
85
src/llm/effort-injection.test.ts
Normal file
85
src/llm/effort-injection.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildJobExtraBody, type EffortInjectionWorker } from './effort-injection.js';
|
||||
|
||||
describe('buildJobExtraBody', () => {
|
||||
it('jobEffort null → returns worker.extraBody unchanged', () => {
|
||||
const extraBody = { top_k: 20 };
|
||||
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||
const result = buildJobExtraBody(worker, null);
|
||||
expect(result).toEqual(extraBody);
|
||||
expect(extraBody).toEqual({ top_k: 20 }); // not mutated
|
||||
});
|
||||
|
||||
it('jobEffort undefined → returns worker.extraBody unchanged', () => {
|
||||
const extraBody = { top_k: 20 };
|
||||
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||
const result = buildJobExtraBody(worker, undefined);
|
||||
expect(result).toEqual(extraBody);
|
||||
});
|
||||
|
||||
it('mode unset (default body) + effort in reasoningEfforts → injected at top level', () => {
|
||||
const extraBody = { top_k: 20 };
|
||||
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low', 'max'] };
|
||||
const result = buildJobExtraBody(worker, 'max');
|
||||
expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||
});
|
||||
|
||||
it("mode 'chat_template_kwargs' + effort in efforts → nested, preserving existing sub-keys", () => {
|
||||
const extraBody = { top_k: 20, chat_template_kwargs: { enable_thinking: true } };
|
||||
const worker: EffortInjectionWorker = {
|
||||
extraBody,
|
||||
reasoningEfforts: ['low', 'high'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
};
|
||||
const result = buildJobExtraBody(worker, 'high');
|
||||
expect(result).toEqual({
|
||||
top_k: 20,
|
||||
chat_template_kwargs: { enable_thinking: true, reasoning_effort: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('effort NOT in reasoningEfforts → no injection, extraBody unchanged', () => {
|
||||
const extraBody = { top_k: 20 };
|
||||
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['low'] };
|
||||
const result = buildJobExtraBody(worker, 'high');
|
||||
expect(result).toEqual(extraBody);
|
||||
});
|
||||
|
||||
it('reasoningEfforts undefined + effort set → no injection', () => {
|
||||
const extraBody = { top_k: 20 };
|
||||
const worker: EffortInjectionWorker = { extraBody };
|
||||
const result = buildJobExtraBody(worker, 'high');
|
||||
expect(result).toEqual(extraBody);
|
||||
});
|
||||
|
||||
it('extraBody undefined + effort valid → injects into a fresh object', () => {
|
||||
const worker: EffortInjectionWorker = { reasoningEfforts: ['max'] };
|
||||
const result = buildJobExtraBody(worker, 'max');
|
||||
expect(result).toEqual({ reasoning_effort: 'max' });
|
||||
});
|
||||
|
||||
it('chat_template_kwargs is a non-object → does not throw, replaced with fresh object', () => {
|
||||
const extraBody = { chat_template_kwargs: 'oops' as unknown as Record<string, unknown> };
|
||||
const worker: EffortInjectionWorker = {
|
||||
extraBody,
|
||||
reasoningEfforts: ['high'],
|
||||
reasoningEffortMode: 'chat_template_kwargs',
|
||||
};
|
||||
let result: Record<string, unknown> | undefined;
|
||||
expect(() => {
|
||||
result = buildJobExtraBody(worker, 'high');
|
||||
}).not.toThrow();
|
||||
expect(result).toEqual({ chat_template_kwargs: { reasoning_effort: 'high' } });
|
||||
});
|
||||
|
||||
it('input not mutated: frozen extraBody → returns a new object without throwing', () => {
|
||||
const extraBody = Object.freeze({ top_k: 20 });
|
||||
const worker: EffortInjectionWorker = { extraBody, reasoningEfforts: ['max'] };
|
||||
let result: Record<string, unknown> | undefined;
|
||||
expect(() => {
|
||||
result = buildJobExtraBody(worker, 'max');
|
||||
}).not.toThrow();
|
||||
expect(result).toEqual({ top_k: 20, reasoning_effort: 'max' });
|
||||
expect(result).not.toBe(extraBody);
|
||||
});
|
||||
});
|
||||
58
src/llm/effort-injection.ts
Normal file
58
src/llm/effort-injection.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export interface EffortInjectionWorker {
|
||||
extraBody?: Record<string, unknown>;
|
||||
reasoningEfforts?: string[];
|
||||
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a job's requested reasoning effort onto the worker's extra_body.
|
||||
*
|
||||
* - jobEffort null/undefined → return worker.extraBody unchanged
|
||||
* - effort not declared in worker.reasoningEfforts (or none declared) → do
|
||||
* NOT inject; return worker.extraBody unchanged and log a warning
|
||||
* (defensive: the enqueue path validates, so this only fires on a
|
||||
* config-reload race)
|
||||
* - mode 'body' (or unset) → { ...extraBody, reasoning_effort: jobEffort }
|
||||
* - mode 'chat_template_kwargs' → nested under chat_template_kwargs,
|
||||
* preserving any existing chat_template_kwargs sub-keys
|
||||
*/
|
||||
export function buildJobExtraBody(
|
||||
worker: EffortInjectionWorker,
|
||||
jobEffort: string | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (jobEffort === null || jobEffort === undefined) {
|
||||
return worker.extraBody;
|
||||
}
|
||||
|
||||
const supported = worker.reasoningEfforts ?? [];
|
||||
if (!supported.includes(jobEffort)) {
|
||||
logger.warn(`[effort-injection] effort not supported by worker, skipping effort=${jobEffort}`);
|
||||
return worker.extraBody;
|
||||
}
|
||||
|
||||
const extraBody = worker.extraBody ?? {};
|
||||
|
||||
if (worker.reasoningEffortMode === 'chat_template_kwargs') {
|
||||
const existingKwargs = isPlainObject(extraBody.chat_template_kwargs)
|
||||
? extraBody.chat_template_kwargs
|
||||
: {};
|
||||
return {
|
||||
...extraBody,
|
||||
chat_template_kwargs: {
|
||||
...existingKwargs,
|
||||
reasoning_effort: jobEffort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...extraBody,
|
||||
reasoning_effort: jobEffort,
|
||||
};
|
||||
}
|
||||
119
src/llm/openai-compat.extra-body.test.ts
Normal file
119
src/llm/openai-compat.extra-body.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OpenAICompatClient, type Message } from './openai-compat.js';
|
||||
|
||||
// fetch モックで送信 body をキャプチャする(openai-compat.test.ts と同じパターン)
|
||||
function makeSseResponse(chunks: Array<Record<string, unknown> | '[DONE]'>): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
const data = chunk === '[DONE]' ? chunk : JSON.stringify(chunk);
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
async function drain(client: OpenAICompatClient, messages: Message[]): Promise<void> {
|
||||
for await (const _event of client.chat(messages)) {
|
||||
// 送信 body のキャプチャが目的。イベント内容は他テストで検証済み。
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenAICompatClient extraBody', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('extraBody の非予約キーがリクエスト body にマージされる', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
makeSseResponse([{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]']),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = new OpenAICompatClient(
|
||||
'http://llm.test/v1',
|
||||
'test-model',
|
||||
undefined,
|
||||
{ maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ extraBody: { reasoning_effort: 'max', top_k: 20 } },
|
||||
);
|
||||
|
||||
await drain(client, [{ role: 'user', content: 'hi' }]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body.reasoning_effort).toBe('max');
|
||||
expect(body.top_k).toBe(20);
|
||||
});
|
||||
|
||||
it('extraBody の予約キーは除去され、client 側の値が勝つ', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
makeSseResponse([{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]']),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const client = new OpenAICompatClient(
|
||||
'http://llm.test/v1',
|
||||
'test-model',
|
||||
undefined,
|
||||
{ maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
extraBody: {
|
||||
model: 'evil',
|
||||
messages: [],
|
||||
stream: false,
|
||||
reasoning_effort: 'max',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await drain(client, [{ role: 'user', content: 'hi' }]);
|
||||
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(body.model).toBe('test-model');
|
||||
expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
||||
expect(body.stream).toBe(true);
|
||||
expect(body.reasoning_effort).toBe('max');
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('extraBody 未指定なら body のキー集合は従来どおり(回帰なし)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
makeSseResponse([{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]']),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = new OpenAICompatClient(
|
||||
'http://llm.test/v1',
|
||||
'test-model',
|
||||
undefined,
|
||||
{ maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
|
||||
);
|
||||
|
||||
await drain(client, [{ role: 'user', content: 'hi' }]);
|
||||
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const body = JSON.parse(requestInit.body as string);
|
||||
expect(Object.keys(body).sort()).toEqual(['messages', 'model', 'stream', 'stream_options'].sort());
|
||||
});
|
||||
});
|
||||
@ -337,6 +337,32 @@ export interface OpenAICompatClientOptions {
|
||||
* unknown body fields, so this must never default to on.
|
||||
*/
|
||||
requestPromptProgress?: boolean;
|
||||
/**
|
||||
* Arbitrary extra fields to shallow-merge into the request body (e.g.
|
||||
* `reasoning_effort` for vLLM-hosted deepseek). Reserved keys that the
|
||||
* client itself constructs (model/messages/stream/stream_options/tools/
|
||||
* tool_choice/temperature) are stripped — the client's own values always
|
||||
* win. Sanitized once in the constructor.
|
||||
*/
|
||||
extraBody?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** extra_body から除去する予約キー(client が組み立てる固定フィールド) */
|
||||
const RESERVED_BODY_KEYS = new Set([
|
||||
'model', 'messages', 'stream', 'stream_options', 'tools', 'tool_choice', 'temperature',
|
||||
]);
|
||||
|
||||
function sanitizeExtraBody(extra: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
if (!extra) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (RESERVED_BODY_KEYS.has(k)) {
|
||||
logger.warn(`OpenAICompatClient: extra_body の予約キー '${k}' を無視しました`);
|
||||
continue;
|
||||
}
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -385,9 +411,11 @@ export class OpenAICompatClient {
|
||||
this.maxStreamMs = options?.maxStreamMs ?? this.timeoutMs * 2;
|
||||
this.proxy = options?.proxy === true;
|
||||
this.requestPromptProgress = options?.requestPromptProgress === true;
|
||||
this.extraBody = sanitizeExtraBody(options?.extraBody);
|
||||
}
|
||||
|
||||
private readonly requestPromptProgress: boolean;
|
||||
private readonly extraBody: Record<string, unknown>;
|
||||
|
||||
private buildAbortErrorMessage(externalSignal?: AbortSignal, hardCapHit = false): string {
|
||||
if (externalSignal?.aborted) {
|
||||
@ -511,6 +539,7 @@ export class OpenAICompatClient {
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
...this.extraBody,
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
|
||||
@ -8,6 +8,7 @@ function ev(partial: Partial<EventBase> & { kind: string }): EventBase {
|
||||
eventId: partial.eventId ?? `e${partial.seq ?? 0}`, runId: 'job-run',
|
||||
kind: partial.kind, payload: partial.payload ?? {},
|
||||
correlationId: partial.correlationId, movement: partial.movement,
|
||||
delegateRunId: partial.delegateRunId,
|
||||
} as EventBase;
|
||||
}
|
||||
|
||||
@ -28,6 +29,30 @@ describe('reconstructDelegateRuns', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('本番のイベント形状: tool_call は独自 correlationId を持つが delegateRunId で集計される', () => {
|
||||
// 実機では tool-execution.ts が tool_call/tool_result に per-tool の
|
||||
// correlationId を付与し、子スコープの delegateRunId フィールドで実行に紐づく。
|
||||
// 旧実装は correlationId 突き合わせだったため toolCalls が常に 0 になっていた。
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1 } }),
|
||||
ev({ seq: 2, kind: 'tool_call', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }),
|
||||
ev({ seq: 3, kind: 'tool_result', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }),
|
||||
ev({ seq: 4, kind: 'tool_call', correlationId: 'tool-uuid-b', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'Write', args: { file_path: 'out.txt' } } }),
|
||||
ev({ seq: 5, kind: 'llm_call_end', delegateRunId: 'R1', movement: 'delegate:x', payload: { promptTokens: 10, completionTokens: 5 } }),
|
||||
ev({ seq: 6, kind: 'delegate_complete', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' } }),
|
||||
];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs[0].toolCalls).toBe(2);
|
||||
expect(runs[0].toolSummary).toEqual([
|
||||
{ tool: 'WebFetch', count: 1 },
|
||||
{ tool: 'Write', count: 1 },
|
||||
]);
|
||||
expect(runs[0].filesChanged).toEqual(['out.txt']);
|
||||
expect(runs[0].totalTokens).toBe(15);
|
||||
// eventCount は correlationId ではなく delegateRunId 帰属で数える
|
||||
expect(runs[0].eventCount).toBe(4);
|
||||
});
|
||||
|
||||
it('complete 欠落 → status running', () => {
|
||||
const events = [ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R2', parentRunId: null, description: 'x', depth: 1 } })];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
@ -170,6 +195,38 @@ describe('reconstructDelegateRuns', () => {
|
||||
expect(runs[0].filesChanged).toEqual([]);
|
||||
});
|
||||
|
||||
it('lastErrorTool: tool_result で isError:true が2件連続すると最後の tool 名が残る', () => {
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R14', parentRunId: null, description: 'test', depth: 1 } }),
|
||||
ev({ seq: 2, kind: 'tool_result', correlationId: 'R14', payload: { tool: 'WebFetch', isError: true } }),
|
||||
ev({ seq: 3, kind: 'tool_result', correlationId: 'R14', payload: { tool: 'Bash', isError: true } }),
|
||||
ev({ seq: 4, kind: 'delegate_complete', payload: { delegateRunId: 'R14', parentRunId: null, description: 'test', depth: 1, next: 'ABORT', status: 'aborted' } }),
|
||||
];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs[0].lastErrorTool).toBe('Bash');
|
||||
});
|
||||
|
||||
it('lastErrorTool: isError な tool_result が無ければ null のまま', () => {
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R15', parentRunId: null, description: 'test', depth: 1 } }),
|
||||
ev({ seq: 2, kind: 'tool_result', correlationId: 'R15', payload: { tool: 'WebFetch', isError: false } }),
|
||||
ev({ seq: 3, kind: 'delegate_complete', payload: { delegateRunId: 'R15', parentRunId: null, description: 'test', depth: 1, next: 'ABORT', status: 'aborted' } }),
|
||||
];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs[0].lastErrorTool).toBeNull();
|
||||
});
|
||||
|
||||
it('lastErrorTool: success run でも isError な tool_result があれば値が入る', () => {
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R16', parentRunId: null, description: 'test', depth: 1 } }),
|
||||
ev({ seq: 2, kind: 'tool_result', correlationId: 'R16', payload: { tool: 'WebFetch', isError: true } }),
|
||||
ev({ seq: 3, kind: 'tool_result', correlationId: 'R16', payload: { tool: 'WebFetch', isError: false } }),
|
||||
ev({ seq: 4, kind: 'delegate_complete', payload: { delegateRunId: 'R16', parentRunId: null, description: 'test', depth: 1, next: 'COMPLETE', status: 'success' } }),
|
||||
];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs[0].lastErrorTool).toBe('WebFetch');
|
||||
});
|
||||
|
||||
it('tool_call に args がなくてもクラッシュしない', () => {
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R13', parentRunId: null, description: 'test', depth: 1 } }),
|
||||
|
||||
@ -19,6 +19,7 @@ export interface DelegateRun {
|
||||
totalTokens: number;
|
||||
toolSummary: Array<{ tool: string; count: number }>;
|
||||
filesChanged: string[];
|
||||
lastErrorTool: string | null;
|
||||
}
|
||||
|
||||
function payloadOf(e: EventBase): Record<string, unknown> {
|
||||
@ -50,6 +51,7 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] {
|
||||
totalTokens: 0,
|
||||
toolSummary: [],
|
||||
filesChanged: [],
|
||||
lastErrorTool: null,
|
||||
});
|
||||
order.push(id);
|
||||
}
|
||||
@ -68,9 +70,14 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] {
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'delegate_start') continue;
|
||||
// 内部イベント: correlationId が run の ID に一致するものを束ねる
|
||||
if (e.correlationId && byId.has(e.correlationId)) {
|
||||
const run = byId.get(e.correlationId)!;
|
||||
// 内部イベント: delegateRunId 帰属タグが run の ID に一致するものを束ねる。
|
||||
// correlationId は tool_call↔tool_result のペアリングで per-tool UUID に
|
||||
// 上書きされるため集計キーには使えない(delegateRunId は子スコープが刻印し
|
||||
// 上書きされない)。旧イベント(delegateRunId 欠落)は correlationId に
|
||||
// フォールバックして後方互換を保つ。
|
||||
const attributionId = e.delegateRunId ?? e.correlationId;
|
||||
if (attributionId && byId.has(attributionId)) {
|
||||
const run = byId.get(attributionId)!;
|
||||
run.eventCount++;
|
||||
if (e.kind === 'tool_call') {
|
||||
run.toolCalls++;
|
||||
@ -94,6 +101,14 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.kind === 'tool_result') {
|
||||
const p = payloadOf(e);
|
||||
const tool = typeof p.tool === 'string' ? p.tool : null;
|
||||
if (tool && p.isError) {
|
||||
// seq 昇順で走査しているため、最後に代入された値が「最後に失敗したツール」になる
|
||||
run.lastErrorTool = tool;
|
||||
}
|
||||
}
|
||||
if (e.kind === 'llm_call_end') {
|
||||
const p = payloadOf(e);
|
||||
const promptTokens = typeof p.promptTokens === 'number' ? p.promptTokens : 0;
|
||||
|
||||
@ -148,6 +148,18 @@ describe('FileEventLogger', () => {
|
||||
expect(parsed.iteration).toBe(5);
|
||||
});
|
||||
|
||||
it('child() の delegateRunId は per-event correlationId 上書きでも保持される', () => {
|
||||
// delegate 実行の帰属タグ回帰: tool_call は自前の correlationId を付けるが、
|
||||
// scope の delegateRunId は残らねばならない(これが 0 回バグの再発防止)。
|
||||
const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
|
||||
const child = log.child({ movement: 'delegate:x', delegateRunId: 'RUN-9' });
|
||||
const perToolCorr = child.startCorrelation();
|
||||
child.emit('tool_call', { tool: 'Read' }, { correlationId: perToolCorr, llmToolCallId: 'r-1' });
|
||||
const parsed = JSON.parse(readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim()) as EventBase;
|
||||
expect(parsed.delegateRunId).toBe('RUN-9');
|
||||
expect(parsed.correlationId).toBe(perToolCorr); // ペアリング用は上書きされたまま
|
||||
});
|
||||
|
||||
// root ignores chmod-based write denial, so this only holds as non-root (e.g. CI running as root).
|
||||
it.skipIf(process.getuid?.() === 0)('does NOT throw when the workspace directory is not writable (failure isolation)', () => {
|
||||
// Make the workspace read-only so appendFileSync fails.
|
||||
|
||||
@ -56,6 +56,13 @@ export interface EventBase {
|
||||
parentEventId?: string;
|
||||
correlationId?: string;
|
||||
llmToolCallId?: string;
|
||||
/**
|
||||
* delegate サブ実行への帰属。子スコープが刻印し、個々の tool_call が
|
||||
* `correlationId` を上書きしても失われない。reconstructDelegateRuns が
|
||||
* 内部イベントを実行へ束ねるのに使う(correlationId は tool_call↔tool_result
|
||||
* のペアリング専用に残す)。
|
||||
*/
|
||||
delegateRunId?: string;
|
||||
movement?: string;
|
||||
iteration?: number;
|
||||
kind: string;
|
||||
@ -66,6 +73,7 @@ export interface EmitOptions {
|
||||
parentEventId?: string;
|
||||
correlationId?: string;
|
||||
llmToolCallId?: string;
|
||||
delegateRunId?: string;
|
||||
movement?: string;
|
||||
iteration?: number;
|
||||
}
|
||||
@ -162,7 +170,7 @@ function fitToEnvelope(eventLine: string, event: EventBase): string {
|
||||
export interface EventLogger {
|
||||
emit(kind: string, payload: unknown, opts?: EmitOptions): string;
|
||||
startCorrelation(): string;
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger;
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger;
|
||||
/** Ids and counters for diagnostics. */
|
||||
describe(): { runId: string; seq: number; degraded: boolean };
|
||||
}
|
||||
@ -176,7 +184,7 @@ export class NoopEventLogger implements EventLogger {
|
||||
startCorrelation(): string {
|
||||
return 'noop';
|
||||
}
|
||||
child(_scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger {
|
||||
child(_scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger {
|
||||
return this;
|
||||
}
|
||||
describe(): { runId: string; seq: number; degraded: boolean } {
|
||||
@ -198,7 +206,7 @@ interface FileLoggerCore {
|
||||
class ScopedEventLogger implements EventLogger {
|
||||
constructor(
|
||||
private readonly core: FileLoggerCore,
|
||||
private readonly scope: { movement?: string; iteration?: number; correlationId?: string },
|
||||
private readonly scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string },
|
||||
) {}
|
||||
|
||||
emit(kind: string, payload: unknown, opts?: EmitOptions): string {
|
||||
@ -213,6 +221,7 @@ class ScopedEventLogger implements EventLogger {
|
||||
parentEventId: opts?.parentEventId,
|
||||
correlationId: opts?.correlationId ?? this.scope.correlationId,
|
||||
llmToolCallId: opts?.llmToolCallId,
|
||||
delegateRunId: opts?.delegateRunId ?? this.scope.delegateRunId,
|
||||
movement: opts?.movement ?? this.scope.movement,
|
||||
iteration: opts?.iteration ?? this.scope.iteration,
|
||||
kind,
|
||||
@ -239,11 +248,12 @@ class ScopedEventLogger implements EventLogger {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger {
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger {
|
||||
return new ScopedEventLogger(this.core, {
|
||||
movement: scope.movement ?? this.scope.movement,
|
||||
iteration: scope.iteration ?? this.scope.iteration,
|
||||
correlationId: scope.correlationId ?? this.scope.correlationId,
|
||||
delegateRunId: scope.delegateRunId ?? this.scope.delegateRunId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
* care which runtime ran, only that the output is correct.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { transformCamel } from './migrate-config.js';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
@ -125,3 +126,20 @@ describe('migrate-config CLI', () => {
|
||||
expect(r.stderr).toMatch(/config_version=99/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformCamel', () => {
|
||||
it('extra_body の中身のキーは camelCase 変換せず素通しする(src/config.ts transformKeys と同じ不変条件)', () => {
|
||||
const input = {
|
||||
provider: {
|
||||
workers: [
|
||||
{ id: 'w1', extra_body: { reasoning_effort: 'max', chat_template_kwargs: { enable_thinking: true } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = transformCamel(input) as any;
|
||||
expect(out.provider.workers[0].extraBody).toEqual({
|
||||
reasoning_effort: 'max',
|
||||
chat_template_kwargs: { enable_thinking: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -27,9 +27,10 @@
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { normalizeConfig, UnsupportedConfigVersionError } from '../config-normalize.js';
|
||||
import { toSnakeKeys } from '../config.js';
|
||||
import { toSnakeKeys, OPAQUE_CONFIG_KEYS } from '../config.js';
|
||||
|
||||
interface Cli {
|
||||
dryRun: boolean;
|
||||
@ -82,15 +83,21 @@ function printHelp(): void {
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
/** snake_case → camelCase recursive (matches src/config.ts transformKeys). */
|
||||
/** snake_case → camelCase recursive (matches src/config.ts transformKeys).
|
||||
* extra_body 等の不透明キー(OPAQUE_CONFIG_KEYS)の値はプロバイダーへ素通しする
|
||||
* JSON なので、src/config.ts と同様にキー変換せずそのまま通す。
|
||||
* Exported for tests. */
|
||||
function toCamel(s: string): string {
|
||||
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
function transformCamel(obj: unknown): unknown {
|
||||
export function transformCamel(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(transformCamel);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), transformCamel(v)]),
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
||||
toCamel(k),
|
||||
OPAQUE_CONFIG_KEYS.has(k) ? v : transformCamel(v),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
@ -286,5 +293,13 @@ function main(argv: string[]): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const code = main(process.argv.slice(2));
|
||||
process.exit(code);
|
||||
// 直接実行されたときだけ CLI を走らせる(node dist/... / tsx src/... の両経路とも
|
||||
// migrate-config.sh は絶対パスで invoke する)。テストからの import で main /
|
||||
// process.exit が発火しないようにするためのガード。
|
||||
const invokedAsScript =
|
||||
process.argv[1] != null &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
||||
if (invokedAsScript) {
|
||||
const code = main(process.argv.slice(2));
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
56
src/webhook-adapters/discord.test.ts
Normal file
56
src/webhook-adapters/discord.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildDiscordPayload } from './discord.js';
|
||||
import type { NotificationEvent } from './types.js';
|
||||
|
||||
const base: NotificationEvent = {
|
||||
event: 'succeeded',
|
||||
taskId: 42,
|
||||
taskTitle: 'Ship the widget',
|
||||
pieceName: 'chat',
|
||||
spaceName: 'Acme Workspace',
|
||||
taskUrl: 'https://maestro.example.com/?page=spaces&space=s1&chat=42',
|
||||
includeDetails: true,
|
||||
};
|
||||
|
||||
describe('buildDiscordPayload', () => {
|
||||
it('includeDetails=true: embed carries title, piece, workspace, task id, and link', () => {
|
||||
const payload = buildDiscordPayload(base);
|
||||
expect(payload.embeds).toHaveLength(1);
|
||||
const embed = payload.embeds[0];
|
||||
expect(embed.title).toContain('Ship the widget');
|
||||
expect(embed.title).toContain('タスク完了');
|
||||
expect(embed.url).toBe(base.taskUrl);
|
||||
expect(embed.fields).toBeDefined();
|
||||
const fieldValues = embed.fields!.map(f => f.value);
|
||||
expect(fieldValues).toContain('chat');
|
||||
expect(fieldValues).toContain('Acme Workspace');
|
||||
expect(fieldValues).toContain('42');
|
||||
});
|
||||
|
||||
it('includeDetails=false: omits task title, piece name, and workspace name', () => {
|
||||
const payload = buildDiscordPayload({ ...base, includeDetails: false });
|
||||
const embed = payload.embeds[0];
|
||||
expect(embed.title).not.toContain('Ship the widget');
|
||||
expect(embed.title).toContain('#42');
|
||||
expect(embed.fields).toBeUndefined();
|
||||
// Serialized form must not leak the detailed fields either.
|
||||
const json = JSON.stringify(payload);
|
||||
expect(json).not.toContain('Ship the widget');
|
||||
expect(json).not.toContain('Acme Workspace');
|
||||
});
|
||||
|
||||
it('uses a distinct color/emoji per event kind', () => {
|
||||
const succeeded = buildDiscordPayload({ ...base, event: 'succeeded' });
|
||||
const failed = buildDiscordPayload({ ...base, event: 'failed' });
|
||||
const waiting = buildDiscordPayload({ ...base, event: 'waiting_human' });
|
||||
expect(succeeded.embeds[0].color).not.toBe(failed.embeds[0].color);
|
||||
expect(failed.embeds[0].color).not.toBe(waiting.embeds[0].color);
|
||||
expect(failed.embeds[0].title).toContain('❌');
|
||||
expect(waiting.embeds[0].title).toContain('❓');
|
||||
});
|
||||
|
||||
it('handles an empty taskUrl gracefully (no url field)', () => {
|
||||
const payload = buildDiscordPayload({ ...base, taskUrl: '' });
|
||||
expect(payload.embeds[0].url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user