diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc996833c..cddcac1c3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.27.18): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.27.19): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/CHANGELOG.md b/CHANGELOG.md index e73334e89..bcf79892c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.19] - 2026-11-05 + +- 委任の資格情報を画面から発行・取り消しできる。委任の一覧は専用のページで確認できる(#2512)(#2518) +- 人だけが確定できる操作は、委任の資格情報経由で要求すると断られる(#2512)(#2518) +- 利用者向けの操作は、1 回ごとに操作の記録へ追記される(#2512)(#2518) +- MCP の受け口から呼んだツールは、画面と同じアプリの経路で処理される(#2512)(#2518) +- API と画面の間の受け渡しは、要素の配列の形で行う(#2512)(#2518) + +### 移行の手順 + +- Factory: 画面と API は同じ版で同時に切り替わり、旧い形の経路は残らない。API を直接呼んでいる場合は、要素の配列で受け渡す新しい形へ呼び出し側を書き換える(#2512)(#2518) + ## [ndf 10.27.18] - 2026-11-05 - Docs: 並列のレーンの画面の見方と、入力欄のサジェストを判断に使わないことを書く(#2502) diff --git a/README.md b/README.md index ce730a17e..3050eb865 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.18 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.19 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 4.0.0 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.18** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.19** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 49個、Codex向け core 45個です。 元Skills(49個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/docs/glossary.md b/docs/glossary.md index cddb8f0fc..332d429c9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -328,9 +328,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 判断の項目 | `decisions` | 結果 JSON の decisions。スクリプトが決めたことと、その根拠を並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | | 閉じる予定の参照 | — | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | -| 版の差し込み | — | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | — | — | — | -| 版の記録 | — | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む | — | — | — | -| 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない | — | — | — | +| 版の差し込み | — | 前の版の NDF が組んだ配布のプランが、具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。プランの型の移行関数が版の参照へ読み替える(#2344) | — | — | — | +| 版の記録 | — | 解決の控えのうち、版の参照の行。決めた・確かめた版と、決めた根拠と、決め直しの履歴を持ち、流し直しはこれを読む。前の版の NDF が書いた `version.json` は移行関数でこの行へ読み替える(#2344) | — | — | — | +| 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、次に空いている版を決め、解決の控えの版の行をその版へ上書きし、前の版と理由を控えの履歴に残すこと。プランのファイルは書き換えない。明示の版と、自分の配布のマージの後には行わない(#2344) | — | — | — | | 明示の版 | — | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | — | — | — | | 実行の核 | `run_core` | 入口・ステップが必ず通る共通の実行の仕組み。入力の解決・記録・lease・型の確認・再試行と上限・結果の変換を引き受け、ステップは仕事の中身だけを書く(#2335) | — | — | — | | 契約の一覧 | `contract_registry` | エントリポイントごとの入力(引数・環境変数・.ndf/ の設定のキー・標準入力)・出力・書く状態・終了コードと、受け渡しのファイル・設定・環境変数の書き手と読み手と置き場を持つ、リポジトリで 1 つの宣言 | — | — | `docs/specifications/ndf-entrypoint-contracts.md` | @@ -393,6 +393,11 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | +| プランの参照 | — | プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・前のプランが出した PR・承認したコミットと照らすブランチの先端)。流す直前に実行の核が値へ解決する(#2344) | — | — | — | +| 解決の記録 | — | 1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344) | — | — | — | +| 承認時の値 | — | 承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344) | — | — | — | +| 解決の控え | — | 流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344) | — | — | — | +| プランのハッシュ | — | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344) | — | — | — | | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index bdd8b8390..620f61027 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4814,19 +4814,19 @@ { "term": "版の差し込み", "context": "ndf-workflow", - "meaning": "配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める", + "meaning": "前の版の NDF が組んだ配布のプランが、具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。プランの型の移行関数が版の参照へ読み替える(#2344)", "source": "" }, { "term": "版の記録", "context": "ndf-workflow", - "meaning": "配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む", + "meaning": "解決の控えのうち、版の参照の行。決めた・確かめた版と、決めた根拠と、決め直しの履歴を持ち、流し直しはこれを読む。前の版の NDF が書いた `version.json` は移行関数でこの行へ読み替える(#2344)", "source": "" }, { "term": "版の決め直し", "context": "ndf-workflow", - "meaning": "開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない", + "meaning": "開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、次に空いている版を決め、解決の控えの版の行をその版へ上書きし、前の版と理由を控えの履歴に残すこと。プランのファイルは書き換えない。明示の版と、自分の配布のマージの後には行わない(#2344)", "source": "" }, { @@ -5431,6 +5431,36 @@ "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "source": "docs/specifications/ndf-context-restart-policy.md" }, + { + "term": "プランの参照", + "context": "ndf-workflow", + "meaning": "プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・前のプランが出した PR・承認したコミットと照らすブランチの先端)。流す直前に実行の核が値へ解決する(#2344)", + "source": "" + }, + { + "term": "解決の記録", + "context": "ndf-workflow", + "meaning": "1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344)", + "source": "" + }, + { + "term": "承認時の値", + "context": "ndf-workflow", + "meaning": "承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344)", + "source": "" + }, + { + "term": "解決の控え", + "context": "ndf-workflow", + "meaning": "流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344)", + "source": "" + }, + { + "term": "プランのハッシュ", + "context": "ndf-workflow", + "meaning": "プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344)", + "source": "" + }, { "term": "持ち主の契約", "context": "ndf-worktree", diff --git a/docs/metrics/ndf-token-usage/2026-11-05-2.json b/docs/metrics/ndf-token-usage/2026-11-05-2.json new file mode 100644 index 000000000..dfcfc81cc --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05-2.json @@ -0,0 +1,589 @@ +{ + "per_pr": [ + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 33, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 28, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.18", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 8, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 7, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 33, + "cost": 499166.54393939395, + "context": 3431253.6363636362, + "out": 20743.757575757576, + "minutes": 25.609364142080754, + "p": 60699.15151515151, + "k": 28.87878787878788, + "w5": 0.0, + "w1h": 114783.30303030302, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 180838, + "rewrite_tokens_after_5m": 180838, + "read_tokens_after_5m": 2912952, + "rewrite_gap_median": 92.71850833098094 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 5, + "cost": 27607.0, + "context": 13001.0, + "out": 321.4, + "minutes": 0.11266666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 12999.0 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 3, + "cost": 47996.549999999996, + "context": 20586.0, + "out": 1588.0, + "minutes": 0.3555555555555555, + "k": 1.0, + "w5": 0.0, + "w1h": 20013.0 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 13, + "cost": 20065.507692307692, + "context": 13979.538461538461, + "out": 1099.8461538461538, + "minutes": 0.2017948717948718, + "k": 1.0, + "w5": 0.0, + "w1h": 7110.461538461538 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 230859.68333333335, + "context": 1373556.3333333333, + "out": 14592.333333333334, + "minutes": 2.448522220717536, + "p": 25547.666666666668, + "k": 22.0, + "w5": 74315.33333333333, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 3, + "cost": 77433.28333333334, + "context": 89212.0, + "out": 5197.0, + "minutes": 0.7678888877232869, + "p": 3381.6666666666665, + "k": 3.3333333333333335, + "w5": 36974.333333333336, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 24820, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.13893333276112874 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 34, + "cost": 269778.37647058826, + "context": 2028108.8823529412, + "out": 12611.29411764706, + "minutes": 7.253480392156863, + "k": 21.058823529411764, + "w5": 0.0, + "w1h": 53989.470588235294 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 886845.25, + "context": 9165440.0, + "out": 26793.0, + "minutes": 188.82836666504542, + "p": 45248.0, + "k": 62.0, + "w5": 0.0, + "w1h": 151011.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 799216, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 28, + "cost": 340494.6035714285, + "context": 1951432.2142857143, + "out": 13224.607142857143, + "minutes": 6.233439285414559, + "p": 63474.07142857143, + "k": 17.964285714285715, + "w5": 0.0, + "w1h": 90649.07142857143, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 334253, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 5, + "cost": 27697.2, + "context": 13153.6, + "out": 278.4, + "minutes": 0.08499999999999999, + "k": 1.0, + "w5": 0.0, + "w1h": 13151.6 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 9, + "cost": 49233.666666666664, + "context": 25622.444444444445, + "out": 1276.888888888889, + "minutes": 0.25462962962962965, + "k": 1.0, + "w5": 0.0, + "w1h": 21316.0 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 9, + "cost": 16851.977777777778, + "context": 13627.111111111111, + "out": 1013.7777777777778, + "minutes": 0.19851851851851848, + "k": 1.0, + "w5": 0.0, + "w1h": 5692.222222222223 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 4, + "cost": 86984.95, + "context": 94007.0, + "out": 3228.5, + "minutes": 0.5861708323160807, + "p": 36525.25, + "k": 2.0, + "w5": 55115.25, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 19, + "cost": 307021.6921052632, + "context": 2577089.4210526315, + "out": 15315.736842105263, + "minutes": 2.9133728075445746, + "p": 24915.63157894737, + "k": 26.0, + "w5": 84615.94736842105, + "w1h": 0.0, + "rewrites": 5, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 143388, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.6457666635513306 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 21, + "cost": 158834.04285714286, + "context": 843393.5714285715, + "out": 7258.666666666667, + "minutes": 5.813888888888888, + "k": 18.761904761904763, + "w5": 0.0, + "w1h": 41199.95238095238 + }, + { + "version": "10.27.18", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 8, + "cost": 544831.125, + "context": 4002286.5, + "out": 21361.0, + "minutes": 7.199687500298023, + "p": 59717.375, + "k": 31.5, + "w5": 0.0, + "w1h": 121974.75, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 116286, + "rewrite_gap_median": null + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 7, + "cost": 265353.9857142857, + "context": 1368032.0, + "out": 9339.0, + "minutes": 2.734188095160893, + "p": 58671.42857142857, + "k": 14.0, + "w5": 0.0, + "w1h": 77041.42857142857, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 66430.55, + "context": 31043.0, + "out": 1092.0, + "minutes": 0.23666666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 30470.0 + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 30088.0, + "context": 13545.0, + "out": 600.0, + "minutes": 0.125, + "k": 1.0, + "w5": 0.0, + "w1h": 13543.0 + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 4, + "cost": 65867.55, + "context": 65527.0, + "out": 3662.5, + "minutes": 0.6588125000397365, + "p": 24917.25, + "k": 2.0, + "w5": 36895.75, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.18", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 4, + "cost": 299725.075, + "context": 1879764.75, + "out": 13001.75, + "minutes": 2.78, + "k": 20.25, + "w5": 0.0, + "w1h": 72149.25 + } + ], + "external": [ + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 4, + "context": 253228.25, + "out": 5068.75, + "cost": 115206.8125, + "minutes": 4.319233335057894, + "p": 24890.0, + "k": 6.25, + "w5": 0.0, + "w1h": 39584.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 28121, + "rewrite_gap_median": null, + "call_seats": 4 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 3, + "input": 271961.0, + "cached": 228352.0, + "out": 2139.6666666666665, + "minutes": 2.0000500003496806, + "p": 25574.0, + "k": 6.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 3 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.16", + "until": "2026-11-05T02:15:00Z", + "sessions": 77, + "sessions_with_pr": 0, + "unlinked_external": 3166, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 899, + "版を判定できない": 23 + }, + "released": "10.27.19", + "previous": "2026-11-05.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-05-2.md b/docs/metrics/ndf-token-usage/2026-11-05-2.md new file mode 100644 index 000000000..40c0102c5 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05-2.md @@ -0,0 +1,109 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-05 集計・10.27.19 の配布) + +**10.27.19 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-05T02:15:00Z`。表の最初の行は 10.27.16(前の記録: 2026-11-05.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.19 --until 2026-11-05T02:15:00Z --min-version 10.27.16 +python3 scripts/token-usage.py --min-version 10.27.16 --until 2026-11-05T02:15:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.16, 10.27.18, 10.27.19。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.16, 10.27.18。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.16 | conductor | 62 | 61.7k | 24.5 | 0.00M | 0.10M | 2 | 2 | 0.03 | +| 10.27.16 | supervisor | 73 | 24.4k | 8.5 | 0.03M | 0.01M | 6 | 0 | 0.21 | +| 10.27.16 | worker | 55 | - | 20.2 | 0.00M | 0.05M | - | - | - | +| 10.27.18 | conductor | 15 | 59.2k | 23.3 | 0.00M | 0.10M | 0 | 0 | 0.00 | +| 10.27.18 | supervisor | 6 | 24.9k | 1.7 | 0.02M | 0.01M | 0 | 0 | 0.00 | +| 10.27.18 | worker | 4 | - | 20.2 | 0.00M | 0.07M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 77 件 / PR を作った会話: 0 件 / 寄せ先の無い外部 CLI: 3166 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.16 | conductor | - | - | 62 | 0.43M | 2.86M | 17.4k | 19.5 | +| 10.27.16 | supervisor | judge | claude -p | 10 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.27.16 | supervisor | mvv | claude -p | 12 | 0.05M | 0.02M | 1.4k | 0.3 | +| 10.27.16 | supervisor | pr | claude -p | 22 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.16 | supervisor | その他 | general-purpose | 4 | 0.09M | 0.09M | 3.2k | 0.6 | +| 10.27.16 | supervisor | その他 | ndf:worker | 22 | 0.30M | 2.41M | 15.2k | 2.8 | +| 10.27.16 | supervisor | その他 | その他 | 3 | 0.08M | 0.09M | 5.2k | 0.8 | +| 10.27.16 | worker | work | claude -p | 55 | 0.23M | 1.58M | 10.6k | 6.7 | +| 10.27.18 | conductor | - | - | 15 | 0.41M | 2.77M | 15.8k | 5.1 | +| 10.27.18 | supervisor | mvv | claude -p | 1 | 0.07M | 0.03M | 1.1k | 0.2 | +| 10.27.18 | supervisor | pr | claude -p | 1 | 0.03M | 0.01M | 0.6k | 0.1 | +| 10.27.18 | supervisor | その他 | ndf:worker | 4 | 0.07M | 0.07M | 3.7k | 0.7 | +| 10.27.18 | worker | work | claude -p | 4 | 0.30M | 1.88M | 13.0k | 2.8 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.16 | conductor | - | - | 62 | 61.7k | 24.5 | 0.00M | 0.10M | 2 | 2 | 0.18M | 4.05M | 92.7 | +| 10.27.16 | supervisor | judge | claude -p | 10 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.16 | supervisor | mvv | claude -p | 12 | - | 1.0 | 0.00M | 0.02M | - | - | - | - | - | +| 10.27.16 | supervisor | pr | claude -p | 22 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.16 | supervisor | その他 | general-purpose | 4 | 36.5k | 2.0 | 0.06M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.16 | supervisor | その他 | ndf:worker | 22 | 25.0k | 25.5 | 0.08M | 0.00M | 5 | 0 | 0.00M | 0.00M | 0.6 | +| 10.27.16 | supervisor | その他 | その他 | 3 | 3.4k | 3.3 | 0.04M | 0.00M | 1 | 0 | 0.00M | 0.00M | 0.1 | +| 10.27.16 | worker | work | claude -p | 55 | - | 20.2 | 0.00M | 0.05M | - | - | - | - | - | +| 10.27.18 | conductor | - | - | 15 | 59.2k | 23.3 | 0.00M | 0.10M | 0 | 0 | 0.00M | 0.12M | - | +| 10.27.18 | supervisor | mvv | claude -p | 1 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.18 | supervisor | pr | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.18 | supervisor | その他 | ndf:worker | 4 | 24.9k | 2.0 | 0.04M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.18 | worker | work | claude -p | 4 | - | 20.2 | 0.00M | 0.07M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.16 | claude | cross-review | claude-opus-5-5 | 4 | 0.25M | 0.00M | 5.1k | 0.00 | 4.3 | +| 10.27.16 | codex | cross-review | gpt-6.1-sol | 3 | 0.27M | 0.23M | 2.1k | 0.00 | 2.0 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.16 | claude | cross-review | claude-opus-5-5 | 4 | 24.9k | 6.2 | 0 | 0 | 0.00M | 0.03M | - | +| 10.27.16 | codex | cross-review | gpt-6.1-sol | 3 | 25.6k | 6.0 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md index 9f5f5006f..4408d71d2 100644 --- a/docs/specifications/ndf-factory-accounts-and-plans.md +++ b/docs/specifications/ndf-factory-accounts-and-plans.md @@ -108,7 +108,7 @@ graph TD あわせて `LIMITS_TABLE`・`AccountService`・エラーの型(`ApiError`・`AuthError`・`ForbiddenError`・`PlanError`)と、`AccountContext`・`Caller`・`ExecutionMode`・`Plan`・`SeatType`・`Limits`・`Judgment`・`CurrentUsage`・`LimitReason`・`AuthEnv`・`ApiApp` の型を export する。 -**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts/{account_id}` の `limits` で上限を出す。 +**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts?account_id=` の `limits` で上限を出す。 ### token の検証とアカウントの解決 @@ -214,22 +214,22 @@ sequenceDiagram ### API の経路 -経路はすべて `Authorization: Bearer ` を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。 +経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。 -| 経路 | 入力 | 成功 | 失敗 | +| 経路 | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | -| `GET /me` | — | 200 `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` | -| `GET /accounts/{account_id}` | — | 200 `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` | -| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type}` | 201 アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` | -| `PUT /accounts/{account_id}/execution-mode` | `{execution_mode: "local" \| "byok" \| "managed"}` | 200 アカウントの本文。今と同じ値なら履歴の行を足さずに 200 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | -| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 201 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` | -| `PATCH /accounts/{account_id}/seats/{user_id}` | `{seat_type}` | 200 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 | -| `DELETE /accounts/{account_id}/seats/{user_id}` | — | 204 | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 | +| `GET /me` | — | `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` | +| `GET /accounts?account_id=` | 鍵 `account_id`(要素ごとにアカウントを解決する) | `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` | +| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type, idempotency_key}` | アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` | +| `PATCH /accounts` | `{account_id, execution_mode: "local" \| "byok" \| "managed"}`(要素ごとにアカウントを解決する) | アカウントの本文。今と同じ値なら履歴の行を足さずに成功 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | +| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` | +| `PATCH /accounts/{account_id}/seats` | `{user_id, seat_type}` | 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 | +| `DELETE /accounts/{account_id}/seats?user_id=` | 鍵 `user_id` | `{removed: true}` | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 | - **持ち主の確かめは、席の有無と料金プランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す - **`byok` を選んでもキーの登録はこの経路で求めない。** キーの保存は #2186 が持つ - **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前や料金プランを返さないためである -- 本文が JSON のオブジェクトでなければ 400 `invalid_request`。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える +- 本文が配列でなければ要求全体を、要素がオブジェクトでなければその要素を 400 `invalid_request` にする。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える ### GitHub の仲立ち @@ -434,7 +434,7 @@ graph TD | 他人のアカウント(I18) | 利用者 A の token で、他人の個人のアカウント・入っていないチーム・存在しない ID の 3 つに読む・書くを送ると、すべて 403 で本文が同じであること | | 料金プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | | 履歴(I20) | 席・料金プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | -| 実行の形態の読み出し | `GET /me`・`GET /accounts/{account_id}` の応答と `resolveAccount` の文脈に保存した形態が出ること | +| 実行の形態の読み出し | `GET /me`・`GET /accounts?account_id=` の応答と `resolveAccount` の文脈に保存した形態が出ること | | 実行の形態の変更(I21・I22) | 持ち主の変更は 200 で形態が変わり、持ち主でない者は 403、Free で `managed` は 409 `managed_not_allowed_on_free` で、どちらも形態と履歴が変わらないこと | | Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、料金プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すれば料金プランも変わらないこと | | 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったとき料金プランが `free`・形態が `local` であること | diff --git a/docs/specifications/ndf-factory-contracts-and-progress.md b/docs/specifications/ndf-factory-contracts-and-progress.md index 7f31250ad..d2ad50a79 100644 --- a/docs/specifications/ndf-factory-contracts-and-progress.md +++ b/docs/specifications/ndf-factory-contracts-and-progress.md @@ -362,6 +362,22 @@ sequenceDiagram - **`SavedAnswerReader` は `(approvalRequestId) => Promise` である。** `SavedAnswer` は `{ approvalRequestId; sprintRunId; kind; decision?; answeredByAccountId }` で、`decision` は判断(`kind` が `decision`)では持たない。`null` は未保存、読めないときは投げる(受け口が 503 にする) - **handler は `savedAnswers` に `saved-answers.ts` の `savedAnswersFromEnv()` を渡す。** 今は常に `SavedAnswersUnavailableError` を投げる既定で、`api` の経路は 503 を返す。#2173 が同じファイルの中身を本物の読み出しに替え、受け口のほかのファイルは変えずに済む +### 利用者向けの操作の形 + +画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。 + +| 種類 | メソッド | 入力 | 成功 | +| --- | --- | --- | --- | +| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) | +| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 | +| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 | + +- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす +- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である +- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`) +- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す +- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す + ### 常に成り立つ条件 I10 は全体の契約のもの。#2170 の I24 に続けて番号を振る。I37 は #2186 が #2216 の照合として足し、I44・I45・I50 は #2216 が足した(I32〜I36・I38〜I43 は #2186 のほかの集約の条件、I46〜I49 は [起票の依頼と起票の代行](ndf-factory-issue-requests.md) の台帳の条件)。 diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md index 41e3f8e0b..d153414b6 100644 --- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md +++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md @@ -150,19 +150,19 @@ erDiagram ### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) -どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。ループの経路はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found`。エラーの本文は `{"error": {"code", "message", "details"?}}`。 +どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 -| 経路 | 誰が | 入力 | 成功 | 失敗 | +| 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | -| `POST /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | `{name, milestones, budget?: {laps?, usd?, hours?}}` | 201 `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | -| `GET /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | — | 200 `{loops: [{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}]}`(新しい順 50 件) | 400・403 | -| `GET /accounts/{a}/loops/{id}` | 席を持つ人 | — | 200 `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | -| `PATCH /accounts/{a}/loops/{id}` | 席を持つ人 | `{budget}` | 200 ループの 1 行 | 400・403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/next-lap` | 席を持つ人 | — | 202 `{state}` | 403・404・409 `not_awaiting_user_start` | -| `POST /accounts/{a}/loops/{id}/answers` | 席を持つ人 | `{issue, decision: approved \| rejected, pr?}` | 200 `{pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | -| `PATCH /accounts/{a}/repositories/{r}`(`runs.yaml`) | 持ち主 | `{max_concurrent_runs: 1〜100 の整数か null}` | 200 登録の 1 行 | 400・403・404 | -| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | 既存のまま | 既存のまま | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` | +| `POST /accounts/{a}/loops` | 席を持つ人 | `{repository_id, name, milestones, budget?: {laps?, usd?, hours?}, idempotency_key}` | `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | +| `GET /accounts/{a}/loops?repository_id=` | 席を持つ人 | 条件 `repository_id` | `{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}`(新しい順 50 件) | 要求全体の 400・403 | +| `GET /accounts/{a}/loops/detail?loop_id=` | 席を持つ人 | 鍵 `loop_id` | `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | +| `PATCH /accounts/{a}/loops` | 席を持つ人 | `{loop_id, budget}` | ループの 1 行 | 400・403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/cancel` | 席を持つ人 | `{loop_id}` | `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/next-lap` | 席を持つ人 | `{loop_id}` | `{state}` | 403・404・409 `not_awaiting_user_start` | +| `POST /accounts/{a}/loops/answers` | 席を持つ人(委任の資格情報では断る) | `{loop_id, issue, decision: approved \| rejected, pr?}` | `{loop_id, pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | +| `PATCH /accounts/{a}/repositories`(`runs.yaml`) | 持ち主 | `{repository_id, max_concurrent_runs: 1〜100 の整数か null}` | 登録の 1 行 | 400・403・404 | +| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | [スプリントの実行の経路](ndf-factory-runs-and-runner.md#画面向けの経路) のまま | 同じ | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` | ## 関連リンク diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md index aee611953..7c889c122 100644 --- a/docs/specifications/ndf-factory-dev-loops.md +++ b/docs/specifications/ndf-factory-dev-loops.md @@ -370,7 +370,7 @@ sequenceDiagram ## セキュリティ - サービスは `local` のアカウントと認証を知らない。利用上限の事象はリセット時刻だけを持つ -- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後にループの `accountId` と登録を照らし、他人は 403、無ければ 404 になる +- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後に要素ごとにループの `accountId` と登録を照らし、他人のループの要素は 403、無ければ 404 の要素の結果になる - 同時の実行の数の宣言は持ち主だけが変えられる ## テスト観点 diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md index d39e7c545..7677be2ca 100644 --- a/docs/specifications/ndf-factory-organizer-data-and-contracts.md +++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md @@ -101,11 +101,13 @@ erDiagram ### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`) -| 経路 | 入力 | 成功 | 失敗の形 | +入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。 + +| 経路 | 要素・条件 | 値 | 失敗の形 | | --- | --- | --- | --- | -| `GET /accounts/{accountId}/repositories/{repositoryId}/approval-requests` | — | 200 `{organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/releases` | `?limit`(1〜100、既定 20) | 200 `{items: [{seq, state, release_run_id, presentations, confirmed_k, result, log}]}`(新しい順) | 400・401・403・404 | -| `POST /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve` | `{choice: "continue" \| "reject", body?}` | 202 `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) | +| `GET /accounts/{accountId}/approval-requests?repository_id=` | 鍵 `repository_id` | `{repository_id, organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 | +| `GET /accounts/{accountId}/releases?repository_id=` | 条件 `repository_id`(1 つ)と `limit`(1〜100、既定 20) | `{seq, state, release_run_id, presentations, confirmed_k, result, log}`(新しい順) | 要求全体の 400・401・403・404 | +| `POST /accounts/{accountId}/releases/resolve` | `{repository_id, seq, choice: "continue" \| "reject", body?}`(委任の資格情報では要求全体を 403 `human_only` で断る) | `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) | 一覧と履歴の経路は読むだけで、承認を確定する経路はこのファイルに無い(I66)。 diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md index 621e8bbf6..47eb7c853 100644 --- a/docs/specifications/ndf-factory-runs-and-runner.md +++ b/docs/specifications/ndf-factory-runs-and-runner.md @@ -149,23 +149,23 @@ graph TD ### 画面向けの経路 -機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。 +機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 -| 経路 | 誰が | 入力 | 成功 | 失敗 | +| 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | -| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | 200 `{url}`(`https://github.com/apps//installations/new`) | 403 | -| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | 200 `{url}`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 | -| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り) | 200 `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 | -| `GET /accounts/{a}/repositories` | 席を持つ人 | — | 200 `{repositories: [{repository_id, full_name, installation_id, registered_at, active_sprint_runs}]}` | 403 | -| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 201 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) | -| `DELETE /accounts/{a}/repositories/{repositoryId}` | 持ち主 | — | 204 | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) | -| `GET /accounts/{a}/model-credential` | 持ち主 | — | 200 `{registered, kind, updated_at, external_id}`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 | -| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで) | 200 上と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` | -| `DELETE /accounts/{a}/model-credential` | 持ち主 | — | 204(無くても 204) | 403・503 `credential_unavailable` | -| `POST /accounts/{a}/repositories/{r}/issue-candidates` | 席を持つ人 | `{exclude?: [番号]}`(選び直しで外す番号) | 200 `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` | -| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues}`(1〜20 件の重なりの無い番号) | 201 `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) | -| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | — | 200 `{sprint_runs: [{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]}`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 400(ID の形)・403・503 `progress_unavailable` | -| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` | +| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | `[{url}]`(`https://github.com/apps//installations/new`) | 403 | +| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | `[{url}]`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 | +| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り。要素は 1 個) | `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 | +| `GET /accounts/{a}/repositories` | 席を持つ人 | — | `[{repository_id, full_name, installation_id, registered_at, active_sprint_runs, max_concurrent_runs}]` | 403 | +| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) | +| `DELETE /accounts/{a}/repositories?repository_id=` | 持ち主 | 鍵 `repository_id` | `{removed: true}` | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) | +| `GET /accounts/{a}/model-credential` | 持ち主 | — | `[{registered, kind, updated_at, external_id}]`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 | +| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで)。要素は 1 個 | 取得と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` | +| `DELETE /accounts/{a}/model-credential` | 持ち主 | 鍵なし(要素は 1 個) | `{removed: true}`(無くても成功) | 403・503 `credential_unavailable` | +| `POST /accounts/{a}/issue-candidates` | 席を持つ人 | `{repository_id, exclude?: [番号]}`(選び直しで外す番号) | `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` | +| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues, idempotency_key}`(`issues` は 1〜20 件の重なりの無い番号) | `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) | +| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | 条件 `repository_id`(省くとアカウント全体) | `[{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 要求全体の 400(ID の形)・403・503 `progress_unavailable` | +| `POST /accounts/{a}/sprint-runs/cancel` | 席を持つ人 | `{sprint_run_id}` | `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` | **持ち主だけの経路は、席を持つだけの利用者に他人のアカウントと同じ 403 `forbidden` を返す**(`requireOwner`)。チームのアカウントでは、リポジトリの登録と外し・認可・候補・キーの 3 つの操作は持ち主(`role` が `owner`)だけが、スプリントの開始・一覧・取り消しと課題の候補は席を持つ人が行う。 @@ -430,7 +430,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 | 観点 | 縛る振る舞い | | --- | --- | | 候補と登録(I32・I33) | 偽の GitHub が返した導入の対象のうち、利用者の届くものだけが候補に出ること。証明に無い ID・届かない導入の登録が 403 で行が増えないこと。同じ ID の 2 回目と同時の 2 本が 409 で 1 行であること | -| 外し(I36) | 終わりの状態でない実行があると 409、終われば 204 であること | +| 外し(I36) | 終わりの状態でない実行があると要素の 409、終われば成功の要素(`{removed: true}`)であること | | 他人のアカウント | 他人のアカウントへの経路が #2170 と同じ 403 の本文を返し、持ち主でない席の人の持ち主だけの操作も同じ 403 であること | | キーの持ち込み(I38) | 2 種類の保存・差し替え・消去が通り、応答が 4 項目だけであること。テストのキーの値が、応答・ログ・事象・`RunTask` の環境変数のどこにも無く、ARN は自分のアカウントの `byok` のジョブにだけ載ること | | 外部 ID | 外部 ID がアカウントごとに違い、`AssumeRole` に付くこと。偽の STS が断ると `failed` になること | diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 67817d9cc..6c5990a20 100644 --- a/docs/specifications/ndf-factory-screen-map.md +++ b/docs/specifications/ndf-factory-screen-map.md @@ -1,6 +1,6 @@ # Neural Development Factory: 画面の地図 -Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。 +Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。予定の行に作る課題として書かれた課題は、行を足さずにその行の状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ括弧を消す)。 ## 概要 @@ -40,9 +40,10 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | `/accounts/:accountId/repositories/:repositoryId/loops` | ループ | 個人・管理者・席 | あり | 開発ループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)・始める | | `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める | | `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) | -| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | -| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | -| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | +| `/delegations` | エージェントへの委任 | 個人・管理者・席 | あり | 委任の資格情報の発行(値は発行の直後に 1 度だけ出す)・一覧(範囲・期限・状態)・取り消し。オフラインの間は発行と取り消しをしない。自分のアカウントからたどる | +| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知(作る課題: #2173 承認ゲートの通知(Slack を含む)と Web からの承認) | +| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済(作る課題: #2174 サブスクリプションと Bedrock の token 課金) | +| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍(作る課題: #2189 手元のマシン(利用者の環境で、利用者の claude ログインで実行する)) | ## ナビゲーション @@ -53,6 +54,7 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | 役割 | 主な動線 | | --- | --- | | 個人 | ログイン → 自分のアカウント → リポジトリ → スプリントを始める → スプリントの一覧 → スプリントの詳細 | +| エージェントに任せる人(個人・管理者・席) | 自分のアカウント → エージェントへの委任(発行して値を MCP のクライアントへ写す・取り消す) | | 開発ループを回す人(個人・管理者・席) | リポジトリ → ループ(始める)→ ループの詳細(人の判断待ちに答える・次の周回を始める)→ スプリントの詳細 | | Team の管理者 | ログイン → 自分のアカウント → チームのアカウント(席の管理・実行の形態)→ キーの持ち込み・リポジトリ | | 席 | ログイン → 自分のアカウント → チームのアカウント → リポジトリ → スプリントを始める → スプリントの一覧 | diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index 05d31d582..d8db81a5a 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -494,7 +494,7 @@ stateDiagram-v2 - 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177) - サービスのディレクトリ: [factory/README.md](../../factory/README.md) - 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) -- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) +- 契約の定義と型・進捗の受け口・実行ジョブの token・[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) - 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json) - 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md) - 版と配布の規則(ランナーが導入する NDF に当たる): [版と配布の正本](../versioning-and-distribution.md) diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md index 3de09dbc1..72d888115 100644 --- a/docs/specifications/ndf-factory-sprint-views.md +++ b/docs/specifications/ndf-factory-sprint-views.md @@ -105,14 +105,16 @@ graph TD ### 経路(`factory/contracts/openapi/runs.yaml`) -| 経路 | 応答 | +入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。 + +| 経路 | 値 / 失敗 | | --- | --- | -| `GET /accounts/{accountId}/sprint-runs` | 200 `SprintRunList`。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 403 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}` | 200 `SprintRunDetail` / 403 / 503 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}` | 200 `StepDetail`。`stepKey` は事象の `idempotency_key` を URL で符号化したもの / 403 / 503 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/backlog` | 200 `Backlog` / 403 / 503(保存か GitHub の口の失敗) | +| `GET /accounts/{accountId}/sprint-runs` | 一覧。`SprintRunRow` の配列。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 要求全体の 403 | +| `GET /accounts/{accountId}/sprint-runs/detail?sprint_run_id=` | 指定の取得。`SprintRunDetail` / 要素の 403 / 要素の 503 | +| `GET /accounts/{accountId}/sprint-runs/steps?sprint_run_id=&step_key=` | 指定の取得。実行の ID は 1 つの条件で、鍵は工程の `step_key`(事象の `idempotency_key`)を繰り返す。`StepDetail` / 他人の実行は要求全体の 403、要素の 403 / 503 | +| `GET /accounts/{accountId}/backlogs?repository_id=` | 指定の取得。`Backlog` / 要素の 403 / 要素の 503(保存か GitHub の口の失敗) | -403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}` である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。 +403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}`(要素の結果では `{ok: false, status: 403, error}`)である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。 `SprintRunDetail`: diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md index 02c189884..6e3eca86d 100644 --- a/docs/specifications/ndf-factory-ui-components.md +++ b/docs/specifications/ndf-factory-ui-components.md @@ -88,9 +88,9 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分 | 操作 | 入力 | 出力 | 失敗の形 | | --- | --- | --- | --- | -| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunSummary[]`。見つからない ID の要素は返さない | `ApiFailure`(401・403・5xx) | +| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunDetail[]`(入力と同じ順) | `ApiFailure`(401・要求全体か最初に失敗した要素の 403・5xx。無い実行も 403) | -`sprintRunDetails` の本物の実装は、詳細の経路がまだ無いため `GET /accounts/{a}/sprint-runs` の新しい順 50 件から ID で選ぶ。それより古い実行は見つからない扱いになる。詳細の経路ができたら本物の実装だけを差し替え、画面は変えない。 +`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形))。 ## つながりの状態 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index bb1f17aa0..5b5e194da 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.18` | 利用者が常用してよい | +| 正式版 | `10.27.19` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.18` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.19` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/factory/api/package.json b/factory/api/package.json index dda05bd68..38c107d07 100644 --- a/factory/api/package.json +++ b/factory/api/package.json @@ -17,6 +17,7 @@ "@aws-sdk/lib-dynamodb": "^3.1160.0", "@aws-sdk/s3-request-presigner": "^3.1161.0", "@aws-sdk/util-dynamodb": "^3.996.9", + "@modelcontextprotocol/server": "^2.4.0", "@ndf-factory/contracts": "*", "aws-jwt-verify": "^5.2.1", "hono": "^4.13.14", diff --git a/factory/api/src/accounts/memory-store.ts b/factory/api/src/accounts/memory-store.ts index 613568fe0..b3eb4d659 100644 --- a/factory/api/src/accounts/memory-store.ts +++ b/factory/api/src/accounts/memory-store.ts @@ -1,5 +1,6 @@ import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; // DynamoAccountStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う // beforeWrite は書き込みの直前に呼ばれる。投げると書き込みは何も残さない(トランザクションの失敗) @@ -8,6 +9,8 @@ export class MemoryAccountStore implements AccountStore { readonly accounts = new Map(); readonly seats = new Map(); readonly history: HistoryRecord[] = []; + // 作成の冪等の鍵の行(TTL で消さない) + readonly idempotency = new Map(); beforeWrite: ((op: string) => void | Promise) | null = null; private static seatKey(accountId: string, userId: string) { @@ -56,12 +59,21 @@ export class MemoryAccountStore implements AccountStore { return [...this.seats.values()].filter((s) => s.userId === userId).map((s) => s.accountId); } - async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) { + async getIdempotent(ref: IdempotencyRef, now: string) { + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } + + async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) { const key = MemoryAccountStore.seatKey(ownerSeat.accountId, ownerSeat.userId); + const idemFree = () => { + const row = idempotency && this.idempotency.get(idempotencyPk(idempotency)); + return !row || row.ttl < epochSeconds(idempotency!.createdAt); + }; await this.write( "createTeam", - () => !this.accounts.has(account.accountId) && !this.seats.has(key), + () => !this.accounts.has(account.accountId) && !this.seats.has(key) && idemFree(), () => { + if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); this.accounts.set(account.accountId, { ...account }); this.seats.set(key, { ...ownerSeat }); }, diff --git a/factory/api/src/accounts/model.ts b/factory/api/src/accounts/model.ts index b8db8392d..36ad453ad 100644 --- a/factory/api/src/accounts/model.ts +++ b/factory/api/src/accounts/model.ts @@ -17,9 +17,15 @@ export const PERSONAL_PLANS: readonly Plan[] = ["free", "pro", "max"]; // 新しいアカウントの実行の形態(I24) export const INITIAL_EXECUTION_MODE: ExecutionMode = "local"; +// 呼び出し元の種類(#2384)。Cognito の token は screen、委任の資格情報の直の要求は api、MCP の受け口の内部の要求は mcp +export type Via = "screen" | "api" | "mcp"; + export interface Caller { userId: string; idp: Idp; + // 委任の資格情報で呼んだときだけ。認可は持たず、人の直の操作と同じ解決を通す(I6) + delegation?: { credentialId: string; scope: "read" | "read_write" } | null; + via?: Via; } export interface UserRecord { diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 241a98f96..85de493e0 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -25,6 +25,7 @@ import { type UserRecord, } from "./model.ts"; import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import { claimOf, type IdempotencyRef } from "../idempotency.ts"; // 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」) const VERSION_RETRIES = 3; @@ -161,17 +162,29 @@ export class AccountService { } // チームを作り、作った利用者を持ち主にして持ち主の席を 1 つ作る(受け入れ条件 9、E4) - async createTeam(caller: Caller, name: string, seatType: SeatType): Promise { + // idempotencyKey があれば、同じ利用者の同じ鍵の 24 時間以内の作成を 1 件にし、2 回目は 1 回目のチームを返す(#2384 の I14) + async createTeam(caller: Caller, name: string, seatType: SeatType, idempotencyKey?: string): Promise { await this.ensureUser(caller); const at = this.stamp(); + const ref: IdempotencyRef | undefined = idempotencyKey === undefined ? undefined : { userId: caller.userId, operationId: "createTeam", key: idempotencyKey }; + const replayed = ref ? await this.store.getIdempotent(ref, at) : null; + if (replayed) return this.resolveAccount(caller, replayed); const account = this.newAccount("team", "team", caller.userId, name, at); const seat: SeatRecord = { accountId: account.accountId, userId: caller.userId, seatType, addedAt: at }; try { - await this.store.createTeam(account, seat, [ - this.accountCreated(account, caller.userId, { name }), - this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }), - ]); - } catch { + await this.store.createTeam( + account, + seat, + [ + this.accountCreated(account, caller.userId, { name }), + this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }), + ], + ref && claimOf(ref, account.accountId, at), + ); + } catch (err) { + // 同じ鍵の作成が同時に走り、先に書いた方が勝った + const won = ref && err instanceof ConditionFailedError ? await this.store.getIdempotent(ref, at) : null; + if (won) return this.resolveAccount(caller, won); throw new AccountUnavailableError(); } return this.context(account, caller.userId, seatType); diff --git a/factory/api/src/accounts/store.ts b/factory/api/src/accounts/store.ts index d32b771e1..30e626b18 100644 --- a/factory/api/src/accounts/store.ts +++ b/factory/api/src/accounts/store.ts @@ -8,6 +8,7 @@ import { type TransactWriteCommandInput, } from "@aws-sdk/lib-dynamodb"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; // 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える export class ConditionFailedError extends Error { @@ -33,7 +34,10 @@ export interface AccountStore { // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う listMemberships(userId: string): Promise; - createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]): Promise; + // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14) + createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise; + // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う + getIdempotent(ref: IdempotencyRef, now: string): Promise; // 席が無いときだけ足す(I15) addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise; // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える @@ -232,14 +236,32 @@ export class DynamoAccountStore implements AccountStore { return out; } - async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) { + async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) { await this.transact([ this.put(accountItem(account), NOT_EXISTS), this.put(seatItem(ownerSeat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h))), + ...(idempotency + ? [ + { + Put: { + TableName: this.tableName, + Item: { pk: idempotencyPk(idempotency), sk: "IDEMP", createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl }, + ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) }, + }, + }, + ] + : []), ]); } + async getIdempotent(ref: IdempotencyRef, now: string) { + const item = await this.get({ pk: idempotencyPk(ref), sk: "IDEMP" }); + return liveCreatedId(item && { createdId: String(item.createdId), ttl: Number(item.ttl) }, now); + } + async addSeat(seat: SeatRecord, history: HistoryRecord[]) { await this.transact([this.put(seatItem(seat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h)))]); } diff --git a/factory/api/src/audit/audit.test.ts b/factory/api/src/audit/audit.test.ts new file mode 100644 index 000000000..cc6a50caa --- /dev/null +++ b/factory/api/src/audit/audit.test.ts @@ -0,0 +1,131 @@ +// 操作の記録(#2384 の受け入れ条件 19〜23、不変条件 I7〜I9・I16) +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; +import { DynamoAuditLog } from "./dynamo-log.ts"; +import { AUDIT_COLUMNS } from "./log.ts"; +import { MemoryAuditLog } from "./memory-log.ts"; +import { StructuredLogAudit } from "./structured-log.ts"; + +let api: TestApi; +beforeEach(async () => { + api = await createTestApi({ factory: true }); +}); + +async function personal(user: string): Promise { + return (await (await api.op("getMe", { user })).json())[0].personal_account_id; +} + +const last = () => api.audit.items.at(-1)!; + +describe("1 回の操作に 1 件(受け入れ条件 19・20、I7)", () => { + it("成功の記録は 7 項目と結果を持ち、要求の識別子でログの行とたどれる", async () => { + const acc = await personal("u-1"); + const before = api.audit.items.length; + await api.op("listSprintRuns", { user: "u-1", accountId: acc }); + expect(api.audit.items.length).toBe(before + 1); + expect(last()).toEqual({ + record_id: expect.any(String), + at: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/), + user_id: "u-1", + credential_id: null, + via: "screen", + operation_id: "listSprintRuns", + account_id: acc, + target_ids: [], + outcome: "ok", + status: 200, + reason: null, + }); + expect(api.logs.at(-1)!.request_id).toBe(last().record_id); + }); + + it("一部の要素が失敗すると partial と、失敗の code を重ねずに持つ。対象の ID は要素の鍵", async () => { + const acc = await personal("u-1"); + await api.call("GET", `/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-a&sprint_run_id=sr-b`, { user: "u-1" }); + expect(last()).toMatchObject({ outcome: "partial", reason: "forbidden", status: 200, target_ids: ["sprint_run_id=sr-a", "sprint_run_id=sr-b"] }); + }); + + it("読み取りも、401・403・400 で断った要求も 1 件ずつ残る", async () => { + const acc = await personal("u-1"); + await personal("u-2"); + const before = api.audit.items.length; + await api.op("getMe", { authorization: "Bearer broken" }); + expect(last()).toMatchObject({ outcome: "rejected", status: 401, reason: "unauthorized", user_id: null, operation_id: "getMe", via: "screen" }); + await api.op("listSprintRuns", { user: "u-2", accountId: acc }); + expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "forbidden", user_id: "u-2", operation_id: "listSprintRuns", account_id: acc }); + await api.call("GET", `/accounts/${acc}/sprint-runs/detail`, { user: "u-1" }); + expect(last()).toMatchObject({ outcome: "rejected", status: 400, reason: "empty_items" }); + await api.call("GET", "/nowhere", { user: "u-1" }); + expect(last()).toMatchObject({ status: 404, operation_id: null }); + expect(api.audit.items.length).toBe(before + 4); + }); + + it("委任の資格情報での要求は資格情報の ID と api を持ち、断った人だけの操作も残る", async () => { + const acc = await personal("u-1"); + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: "見本のクライアント", scope: "read", expires_in_seconds: 600, idempotency_key: "a" }] }), + ); + expect(last()).toMatchObject({ operation_id: "createDelegation", target_ids: ["idempotency_key=a", `credential_id=${issued.body.delegation.credential_id}`] }); + const auth = `Bearer ${issued.body.credential}`; + await api.op("listSprintRuns", { authorization: auth, accountId: acc }); + expect(last()).toMatchObject({ via: "api", credential_id: issued.body.delegation.credential_id, user_id: "u-1", outcome: "ok" }); + await api.op("putModelCredential", { authorization: auth, accountId: acc, items: [{ kind: "anthropic_api_key", api_key: "k" }] }); + expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "human_only" }); + }); + + it("外から付けた呼び出し元のヘッダーでは種類が変わらない(I16)", async () => { + await personal("u-1"); + const res = await api.app.request("/me", { headers: { Authorization: await api.tokens.bearer("u-1"), "x-ndf-via": "mcp" } }); + expect(res.status).toBe(200); + expect(last().via).toBe("screen"); + }); + + it("追記に失敗したら 503 audit_unavailable を返す", async () => { + await personal("u-1"); + api.audit.fail = true; + const res = await api.op("getMe", { user: "u-1" }); + expect(res.status).toBe(503); + expect((await res.json()).error.code).toBe("audit_unavailable"); + }); +}); + +describe("書き換えと削除の経路が無い(受け入れ条件 21、I8)", () => { + it("保存の実装は追記の関数だけを持つ", () => { + for (const cls of [MemoryAuditLog, DynamoAuditLog, StructuredLogAudit]) { + expect(Object.getOwnPropertyNames(cls.prototype).sort(), cls.name).toEqual(["append", "constructor"]); + } + }); +}); + +describe("秘密と個人情報を書かない(受け入れ条件 22・23、I9)", () => { + it("キー・ロールの ARN・ログインの token・委任の値・GitHub の code と proof・名前を含む要求でも、記録に値が入らない", async () => { + const acc = await personal("u-1"); + const login = await api.tokens.bearer("u-1"); + const secrets = [ + "sk-ant-SECRET-0001", + "arn:aws:iam::123456789012:role/secret-role", + login.slice("Bearer ".length), + "gh-code-SECRET", + "gh-state-SECRET", + "proof-SECRET", + "秘密のチーム", + "秘密のクライアント", + ]; + await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "anthropic_api_key", api_key: secrets[0] }] }); + await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "aws_role", role_arn: secrets[1], region: "us-east-1" }] }); + await api.op("postRepositoryCandidates", { user: "u-1", accountId: acc, items: [{ code: secrets[3], state: secrets[4] }] }); + await api.op("registerRepository", { user: "u-1", accountId: acc, items: [{ proof: secrets[5], repository_id: 101 }] }); + await api.op("createTeam", { user: "u-1", items: [{ name: secrets[6], seat_type: "standard", idempotency_key: "t" }] }); + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: secrets[7], scope: "read", expires_in_seconds: 600, idempotency_key: "c" }] }), + ); + await api.op("getMe", { authorization: `Bearer ${issued.body.credential}` }); + const text = JSON.stringify(api.audit.items); + for (const s of [...secrets, issued.body.credential]) expect(text, s).not.toContain(s); + }); + + it("記録の列は固定の一覧だけで、メールアドレス・氏名・課題の題と本文を写す列を持たない", async () => { + await personal("u-1"); + for (const item of api.audit.items) expect(Object.keys(item).sort()).toEqual([...AUDIT_COLUMNS].sort()); + }); +}); diff --git a/factory/api/src/audit/dynamo-log.ts b/factory/api/src/audit/dynamo-log.ts new file mode 100644 index 000000000..00ccee2cd --- /dev/null +++ b/factory/api/src/audit/dynamo-log.ts @@ -0,0 +1,19 @@ +// 操作の記録の表(#2384 の「データ構造」)。区分の鍵は record_id だけで、条件つきの追加だけを行う(I8) +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class DynamoAuditLog implements AuditLog { + private readonly doc: DynamoDBDocumentClient; + + constructor( + private readonly tableName: string, + client: DynamoDBClient = new DynamoDBClient({}), + ) { + this.doc = DynamoDBDocumentClient.from(client, { marshallOptions: { removeUndefinedValues: true } }); + } + + async append(record: AuditRecord): Promise { + await this.doc.send(new PutCommand({ TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(record_id)" })); + } +} diff --git a/factory/api/src/audit/log.ts b/factory/api/src/audit/log.ts new file mode 100644 index 000000000..aebf6efc5 --- /dev/null +++ b/factory/api/src/audit/log.ts @@ -0,0 +1,76 @@ +// 操作の記録(#2384 の集約「操作の記録」・不変条件 I7〜I9)。利用者向けの操作 1 回に 1 件を追記する。 +// 保存の契約は追記の関数 1 つだけを持ち、書き換えと削除の関数を持たない(I8)。 +// 記録は要求の本文を写さず、許可した鍵の値と固定の列だけで組む(決定 12・I9) +import { AUDIT_KEYS } from "@ndf-factory/contracts/operations"; +import type { Via } from "../accounts/model.ts"; + +export type AuditOutcome = "ok" | "partial" | "rejected"; + +export interface AuditRecord { + // 要求の識別子(API Gateway の要求の ID、無ければ UUID)。構造化ログの同じ要求の行とたどれる + recordId: string; + // 受けた日時(UTC、ミリ秒まで) + at: string; + // 空は 401 で利用者を決められなかった + userId: string | null; + // 空は人の直の操作か、401 で決められなかった + credentialId: string | null; + via: Via; + // 空は契約に無い経路 + operationId: string | null; + accountId: string | null; + // <鍵の名前>=<値>。許可した鍵だけ + targetIds: string[]; + outcome: AuditOutcome; + status: number; + // rejected の誤りの code。partial は失敗した要素の code を重ねずに , で並べる。ok は空 + reason: string | null; +} + +export interface AuditLog { + append(record: AuditRecord): Promise; +} + +// 記録の列の名前(表の列と同じ)。この一覧の外の列を持たない +export const AUDIT_COLUMNS = [ + "record_id", + "at", + "user_id", + "credential_id", + "via", + "operation_id", + "account_id", + "target_ids", + "outcome", + "status", + "reason", +] as const; + +const ALLOWED: ReadonlySet = new Set(AUDIT_KEYS); + +// 許可した鍵の値だけを残す(鍵の名前が一覧に無い値・形の違う値は捨てる) +export function allowedTargets(targets: readonly string[]): string[] { + const out: string[] = []; + for (const t of targets) { + const i = t.indexOf("="); + if (i < 1 || !ALLOWED.has(t.slice(0, i))) continue; + if (!out.includes(t)) out.push(t); + } + return out; +} + +export function toItem(r: AuditRecord): Record<(typeof AUDIT_COLUMNS)[number], unknown> { + return { + record_id: r.recordId, + at: r.at, + user_id: r.userId, + credential_id: r.credentialId, + via: r.via, + operation_id: r.operationId, + account_id: r.accountId, + target_ids: allowedTargets(r.targetIds), + outcome: r.outcome, + status: r.status, + reason: r.reason, + }; +} diff --git a/factory/api/src/audit/memory-log.ts b/factory/api/src/audit/memory-log.ts new file mode 100644 index 000000000..1d33543d0 --- /dev/null +++ b/factory/api/src/audit/memory-log.ts @@ -0,0 +1,14 @@ +// DynamoAuditLog と同じく追記だけを行う、メモリの上の記録。テストと手元の確かめに使う +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class MemoryAuditLog implements AuditLog { + readonly items: ReturnType[] = []; + // 真なら追記に失敗する(503 の確かめ) + fail = false; + + async append(record: AuditRecord): Promise { + if (this.fail) throw new Error("audit down"); + if (this.items.some((i) => i.record_id === record.recordId)) throw new Error("record exists"); + this.items.push(toItem(record)); + } +} diff --git a/factory/api/src/audit/middleware.ts b/factory/api/src/audit/middleware.ts new file mode 100644 index 000000000..570647e8b --- /dev/null +++ b/factory/api/src/audit/middleware.ts @@ -0,0 +1,87 @@ +// 操作の記録の書き手(#2384 の F6)。利用者向けの要求 1 回ごとに、結果(成功・一部の失敗・400・401・403・404)によらず +// 記録を 1 件追記する。追記に失敗したら記録の全体を構造化ログへ出し、応答を 503 audit_unavailable にする(決定 11・I7) +import { randomUUID } from "node:crypto"; +import type { Context, MiddlewareHandler } from "hono"; +import { OPERATIONS, findOperation, type Operation } from "@ndf-factory/contracts/operations"; +import { isDelegationAuthorization, isMcpInternal, type AuthEnv } from "../auth/resolve.ts"; +import { ServiceUnavailableError } from "../errors.ts"; +import { toItem, type AuditLog, type AuditOutcome, type AuditRecord } from "./log.ts"; + +// MCP の受け口そのものへの要求は操作ではないため記録しない(内部の要求をそれぞれ記録する) +export const MCP_PATH = "/mcp"; + +const PATTERNS = OPERATIONS.map((op) => ({ + op: op as Operation, + re: new RegExp(`^${op.path.replace("{accountId}", "([^/]+)")}$`), +})); + +// 操作の登録の層を通らなかった要求(401 など)の操作を、メソッドと経路から引く +function match(method: string, path: string): { op: Operation; accountId: string | null } | null { + for (const { op, re } of PATTERNS) { + if (op.method !== method) continue; + const m = re.exec(path); + if (m) return { op, accountId: m[1] ? decodeURIComponent(m[1]) : null }; + } + return null; +} + +// API Gateway の要求の ID(Lambda の環境)。無ければ UUID +export function requestIdOf(c: Context): string { + const env = c.env as { event?: { requestContext?: { requestId?: unknown } } } | undefined; + const id = env?.event?.requestContext?.requestId; + return typeof id === "string" && id !== "" ? id : randomUUID(); +} + +async function recordOf(c: Context, at: string): Promise { + const caller = c.get("caller"); + const trace = c.get("operation"); + // 呼び出し元の種類は資格情報の種類と内部の印だけで決め、外から届くほかのヘッダーを見ない(I16) + const delegated = isDelegationAuthorization(c.req.header("Authorization")); + const via = caller?.via ?? (isMcpInternal(c.env) ? "mcp" : delegated ? "api" : "screen"); + const matched = trace ? { op: findOperation(trace.operationId), accountId: trace.accountId } : match(c.req.method, c.req.path); + const status = c.res.status; + let outcome: AuditOutcome = "ok"; + let reason: string | null = null; + if (status !== 200) { + outcome = "rejected"; + const body = (await c.res + .clone() + .json() + .catch(() => null)) as { error?: { code?: unknown } } | null; + reason = typeof body?.error?.code === "string" ? body.error.code : null; + } else if (trace && trace.reasons.length > 0) { + outcome = "partial"; + reason = trace.reasons.join(","); + } + return { + recordId: c.get("requestId") ?? requestIdOf(c), + at, + userId: caller?.userId ?? null, + credentialId: caller?.delegation?.credentialId ?? null, + via, + operationId: matched?.op?.operationId ?? null, + accountId: matched?.accountId ?? null, + targetIds: trace?.targetIds ?? [], + outcome, + status, + reason, + }; +} + +export function recordOperations(log: () => AuditLog | null | undefined, fallback: (line: Record) => void): MiddlewareHandler { + return async (c, next) => { + const at = new Date().toISOString(); + await next(); + if (c.req.method === "OPTIONS" || c.req.path === MCP_PATH) return; + const writer = log(); + if (!writer) return; + const record = await recordOf(c, at); + try { + await writer.append(record); + } catch { + fallback({ audit_fallback: true, record: toItem(record) }); + const err = new ServiceUnavailableError("audit_unavailable"); + c.res = new Response(JSON.stringify(err.body()), { status: 503, headers: { "Content-Type": "application/json" } }); + } + }; +} diff --git a/factory/api/src/audit/structured-log.ts b/factory/api/src/audit/structured-log.ts new file mode 100644 index 000000000..f17970689 --- /dev/null +++ b/factory/api/src/audit/structured-log.ts @@ -0,0 +1,11 @@ +// 操作の記録の表が無い間(インフラが AUDIT_TABLE_NAME を渡す前)の書き手。記録を構造化ログへ 1 行ずつ出し、 +// 後から表へ入れ直せるようにする +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class StructuredLogAudit implements AuditLog { + constructor(private readonly write: (line: string) => void = (line) => console.log(line)) {} + + async append(record: AuditRecord): Promise { + this.write(JSON.stringify({ audit: toItem(record) })); + } +} diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts index ce4849191..2dd8eb5c2 100644 --- a/factory/api/src/auth/resolve.ts +++ b/factory/api/src/auth/resolve.ts @@ -1,14 +1,62 @@ import type { Context, MiddlewareHandler } from "hono"; import type { AccountService } from "../accounts/service.ts"; import type { AccountContext, Caller } from "../accounts/model.ts"; +import type { AuditLog } from "../audit/log.ts"; +import type { DelegationService } from "../delegations/service.ts"; +import { CREDENTIAL_PREFIX } from "../delegations/model.ts"; +import { AuthError } from "../errors.ts"; import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts"; export interface AuthDeps { verifier: AccessTokenVerifier; accounts: AccountService; + // 委任の資格情報の照合(#2384)。無ければ委任の資格情報は 401 + delegations?: DelegationService | null; + // 操作の記録の書き手(#2384)。無ければ記録しない(テストで記録を見ないとき) + audit?: AuditLog | null; } -export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps } }; +// MCP の受け口が内部の要求に付ける印(app.request の第 3 引数の環境に置く)。外から届くヘッダーでは決めない(決定 13・I16) +export const MCP_INTERNAL = Symbol("ndf-factory.mcp-internal"); + +export function isMcpInternal(env: unknown): boolean { + return typeof env === "object" && env !== null && (env as Record)[MCP_INTERNAL] === true; +} + +const DELEGATION_PREFIX = `Bearer ${CREDENTIAL_PREFIX}`; + +// Authorization の値が委任の資格情報か(頭で決める)。認証の分岐(E2)と記録の via(I16)が同じ判定を使う +export function isDelegationAuthorization(header: string | undefined): boolean { + return header?.startsWith(DELEGATION_PREFIX) ?? false; +} + +// Authorization の値を、委任の資格情報(頭が ndfd_)か Cognito の access token として検証する(E2)。どの失敗も 401 +export async function verifyCaller(deps: AuthDeps, authorization: string | undefined, mcp: boolean): Promise { + if (isDelegationAuthorization(authorization)) { + const verified = deps.delegations ? await deps.delegations.verify(authorization!.slice("Bearer ".length)) : null; + if (!verified) throw new AuthError(); + return { + userId: verified.userId, + idp: verified.idp, + delegation: { credentialId: verified.credentialId, scope: verified.scope }, + via: mcp ? "mcp" : "api", + }; + } + // MCP の受け口は委任の資格情報だけを受ける + if (mcp) throw new AuthError(); + return { ...(await verifyAuthorization(deps.verifier, authorization)), delegation: null, via: "screen" }; +} + +// 操作の登録の層が、操作の記録へ渡すために置く値(#2384)。対象の ID は許可した鍵の値だけ +export interface OperationTrace { + operationId: string; + accountId: string | null; + targetIds: string[]; + // 失敗した要素の誤りの code(重ねない) + reasons: string[]; +} + +export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace; requestId?: string } }; // 経路の前に token を毎回検証し、利用者を文脈に置く中間処理(E3)。 // #2171・#2186・#2174 の経路も同じ中間処理を通す @@ -16,7 +64,7 @@ export function requireCaller(deps: () => AuthDeps): MiddlewareHandler return async (c, next) => { const d = deps(); c.set("auth", d); - c.set("caller", await verifyAuthorization(d.verifier, c.req.header("Authorization"))); + c.set("caller", await verifyCaller(d, c.req.header("Authorization"), isMcpInternal(c.env))); await next(); }; } diff --git a/factory/api/src/auth/verify.test.ts b/factory/api/src/auth/verify.test.ts index c1d7cb6a8..33706d1ec 100644 --- a/factory/api/src/auth/verify.test.ts +++ b/factory/api/src/auth/verify.test.ts @@ -37,7 +37,7 @@ describe("token の検証(受け入れ条件 4)", () => { it("正しい token は通り、IdP を username の頭から決める", async () => { const res = await api.call("GET", "/me", { user: "u-2", idp: "GitHub" }); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ user_id: "u-2", idp: "github" }); + expect((await res.json())[0]).toMatchObject({ user_id: "u-2", idp: "github" }); }); it("ログには利用者の ID・経路・状態コードだけを出し、token を出さない", async () => { @@ -45,6 +45,6 @@ describe("token の検証(受け入れ条件 4)", () => { await api.call("GET", "/me", { authorization: auth }); const text = JSON.stringify(api.logs); expect(text).not.toContain(auth.slice("Bearer ".length)); - expect(api.logs.at(-1)).toEqual({ user_id: "u-3", method: "GET", path: "/me", status: 200 }); + expect(api.logs.at(-1)).toEqual({ request_id: expect.any(String), user_id: "u-3", method: "GET", path: "/me", status: 200 }); }); }); diff --git a/factory/api/src/delegations/delegations.test.ts b/factory/api/src/delegations/delegations.test.ts new file mode 100644 index 000000000..99473bf14 --- /dev/null +++ b/factory/api/src/delegations/delegations.test.ts @@ -0,0 +1,193 @@ +// 委任の資格情報と、人だけが確定できる操作(#2384 の受け入れ条件 10〜14・17・18・26、不変条件 I1〜I6・I14) +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { OPERATIONS, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; +import { createTestApi, firstResult, type OpOptions, type TestApi } from "../testing/app.ts"; + +let api: TestApi; +let keys = 0; +beforeEach(async () => { + api = await createTestApi({ factory: true }); +}); + +async function personal(user: string): Promise { + return (await (await api.op("getMe", { user })).json())[0].personal_account_id; +} + +async function issue(user: string, scope: "read" | "read_write", expires_in_seconds = 3600) { + const { status, body } = await firstResult( + await api.op("createDelegation", { user, items: [{ client_name: "手元の Claude Code", scope, expires_in_seconds, idempotency_key: `d-${++keys}` }] }), + ); + expect(status).toBe(200); + return body as { delegation: { credential_id: string; expires_at: string }; credential: string }; +} + +const as = (credential: string) => ({ authorization: `Bearer ${credential}` }); +const agent = (credential: string, id: OperationId, opts: OpOptions = {}) => api.op(id, { ...opts, ...as(credential) }); + +describe("発行(I1・I3・I14)", () => { + it("値は ndfd__<秘密> で 1 度だけ返り、保存した行に平文が無く、ハッシュが値と一致する", async () => { + await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read"); + const m = /^ndfd_([a-z2-7]{26})_(.+)$/.exec(credential)!; + expect(m[1]).toBe(delegation.credential_id); + const row = api.delegationStore.records.get(delegation.credential_id)!; + expect(JSON.stringify(row)).not.toContain(m[2]); + expect(row.secretHash).toBe(createHash("sha256").update(m[2]!).digest("hex")); + const list = await (await api.op("listDelegations", { user: "u-1" })).json(); + expect(list).toEqual([expect.objectContaining({ credential_id: delegation.credential_id, scope: "read", state: "active", revoked_at: null })]); + expect(JSON.stringify(list)).not.toContain(m[2]); + }); + + it.each([299, 3601])("期限 %i 秒は 400 で保存しない", async (seconds) => { + await personal("u-1"); + const res = await firstResult(await api.op("createDelegation", { user: "u-1", items: [{ client_name: "x", scope: "read", expires_in_seconds: seconds, idempotency_key: "k" }] })); + expect(res.status).toBe(400); + expect(api.delegationStore.records.size).toBe(0); + }); + + it("同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を返さない", async () => { + await personal("u-1"); + const item = { client_name: "x", scope: "read", expires_in_seconds: 600, idempotency_key: "same" }; + const first = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] })); + const second = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] })); + expect(second.body.delegation.credential_id).toBe(first.body.delegation.credential_id); + expect(first.body.credential).toMatch(/^ndfd_/); + expect(second.body.credential).toBeNull(); + expect(api.delegationStore.records.size).toBe(1); + }); +}); + +describe("委任の資格情報での認証(受け入れ条件 10〜12、I2・I6)", () => { + it("人の token と同じ人の委任で、同じ操作の許可と拒否が一致する", async () => { + const own = await personal("u-1"); + const other = await personal("u-2"); + const { credential } = await issue("u-1", "read_write"); + const cases: [OperationId, OpOptions][] = [ + ["getAccount", { query: { account_id: [own] } }], + ["getAccount", { query: { account_id: [other] } }], + ["listSprintRuns", { accountId: own }], + ["listSprintRuns", { accountId: other }], + ["setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }], + ["setExecutionMode", { items: [{ account_id: other, execution_mode: "byok" }] }], + ]; + for (const [id, opts] of cases) { + const human = await firstResult(await api.op(id, { ...opts, user: "u-1" })); + const delegated = await firstResult(await agent(credential, id, opts)); + expect(delegated.status, `${id} ${JSON.stringify(opts)}`).toBe(human.status); + } + }); + + it("期限を過ぎた資格情報は 401 で、操作を行わない", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read_write", 300); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + api.clock.now = new Date(api.clock.now.getTime() + 300_000); + const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }); + expect(res.status).toBe(401); + expect(api.store.accounts.get(own)?.executionMode).toBe("local"); + }); + + it("取り消しの後の最初の要求が 401 で、照合の結果をためない", async () => { + const own = await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read"); + const reads = api.delegationStore.reads; + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + expect(api.delegationStore.reads - reads).toBe(2); + const revoked = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } })); + expect(revoked.body.state).toBe("revoked"); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(401); + }); + + it("形の違う値・秘密の違う値・無い ID は 401", async () => { + await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const wrongSecret = credential.slice(0, -1) + (credential.endsWith("A") ? "B" : "A"); + for (const value of ["ndfd_x", wrongSecret, `ndfd_${"a".repeat(26)}_${"b".repeat(43)}`]) { + expect((await api.op("getMe", as(value))).status, value).toBe(401); + } + }); + + it("委任を使わない人の操作は、今と同じく token の検証の失敗が 401、アカウントの解決の失敗が 403(受け入れ条件 26)", async () => { + expect((await api.op("getMe", { authorization: "Bearer broken" })).status).toBe(401); + await personal("u-1"); + expect((await api.op("listSprintRuns", { user: "u-1", accountId: "00000000-0000-4000-8000-0000000000ff" })).status).toBe(403); + }); +}); + +describe("範囲と人だけが確定できる操作(受け入れ条件 13・14・16〜18、I4・I5)", () => { + it("読み取りだけの範囲で書き込みの操作は 403 scope_insufficient で、操作を行わない", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }); + expect(res.status).toBe(403); + expect((await res.json()).error.code).toBe("scope_insufficient"); + expect(api.store.accounts.get(own)?.executionMode).toBe("local"); + }); + + it("委任の資格情報で委任の資格情報の発行を呼ぶと 403", async () => { + await personal("u-1"); + const { credential } = await issue("u-1", "read_write"); + const res = await agent(credential, "createDelegation", { items: [{ client_name: "y", scope: "read_write", expires_in_seconds: 600, idempotency_key: "z" }] }); + expect(res.status).toBe(403); + expect((await res.json()).error.code).toBe("human_only"); + expect(api.delegationStore.records.size).toBe(1); + }); + + it("扱い human の操作をすべて読み書きの範囲で呼ぶと 403 human_only で、何も保存されない", async () => { + const own = await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read_write"); + const snapshot = () => + JSON.stringify([ + [...api.store.accounts.values()], + [...api.store.seats.values()], + api.store.history.length, + [...api.delegationStore.records.values()], + [...api.h!.table.loops.values()], + [...api.h!.table.credentials.values()], + [...api.secrets.values.values()], + ]); + const before = snapshot(); + const human = OPERATIONS.filter((op) => op.access === "human"); + expect(human).toHaveLength(10); + for (const op of human) { + const byQuery = ["select", "delete"].includes(op.kind); + const query = Object.fromEntries(op.keys.length && byQuery ? [[op.keys[0]!, ["x"]]] : []); + if (op.operationId === "revokeDelegation") query.credential_id = [delegation.credential_id]; + const item = { user_id: "u-2", seat_type: "premium", name: "t", kind: "anthropic_api_key", api_key: "sk-SECRET", loop_id: "l", issue: 1, decision: "approved", repository_id: 1, seq: 1, choice: "reject", client_name: "c", scope: "read", expires_in_seconds: 600, idempotency_key: "h" }; + const hasBody = !byQuery; + const res = await api.call(op.method, operationUrl(op, own, query), { ...as(credential), body: hasBody ? [item] : undefined }); + expect(res.status, op.operationId).toBe(403); + expect((await res.json()).error.code, op.operationId).toBe("human_only"); + } + expect(snapshot()).toBe(before); + }); + + it("委任の資格情報で、スプリントの詳細と承認待ちの一覧は読める", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const h = api.h!; + h.table.registrations.set(`${own}#101`, { accountId: own, repositoryId: 101, fullName: "o/r", installationId: 9, registeredAt: "2026-11-04T00:00:00.000Z" } as never); + const list = await firstResult(await agent(credential, "listRepositoryApprovalRequests", { accountId: own, query: { repository_id: [101] } })); + expect(list.status).toBe(200); + expect(list.body.items).toEqual([]); + const detail = await agent(credential, "getSprintRunDetail", { accountId: own, query: { sprint_run_id: ["sr-none"] } }); + expect(detail.status).toBe(200); + expect((await detail.json())[0].error.code).toBe("forbidden"); + }); +}); + +describe("取り消し(E9)", () => { + it("他人の資格情報は forbidden、期限切れの資格情報は何も変えずに成功", async () => { + await personal("u-1"); + await personal("u-2"); + const { delegation } = await issue("u-1", "read", 300); + const others = await firstResult(await api.op("revokeDelegation", { user: "u-2", query: { credential_id: [delegation.credential_id] } })); + expect(others.status).toBe(403); + api.clock.now = new Date(api.clock.now.getTime() + 301_000); + const expired = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } })); + expect(expired.body).toMatchObject({ state: "expired", revoked_at: null }); + expect(api.delegationStore.records.get(delegation.credential_id)?.revokedAt).toBeNull(); + }); +}); diff --git a/factory/api/src/delegations/memory-store.ts b/factory/api/src/delegations/memory-store.ts new file mode 100644 index 000000000..f4b4536a8 --- /dev/null +++ b/factory/api/src/delegations/memory-store.ts @@ -0,0 +1,45 @@ +// DynamoDelegationStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; +import type { DelegationRecord } from "./model.ts"; +import type { DelegationStore } from "./store.ts"; + +export class MemoryDelegationStore implements DelegationStore { + readonly records = new Map(); + readonly idempotency = new Map(); + // 読み出しの回数(取り消しを次の要求から効かせるため、照合をためないことを確かめる) + reads = 0; + + async get(credentialId: string) { + this.reads += 1; + const r = this.records.get(credentialId); + return r ? { ...r } : null; + } + + async create(record: DelegationRecord, idempotency?: IdempotencyClaim) { + if (this.records.has(record.credentialId)) throw new Error("condition failed"); + if (idempotency) { + const row = this.idempotency.get(idempotencyPk(idempotency)); + if (row && row.ttl >= epochSeconds(idempotency.createdAt)) return "duplicate" as const; + this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); + } + this.records.set(record.credentialId, { ...record }); + return "created" as const; + } + + async listByUser(userId: string, limit: number) { + return [...this.records.values()] + .filter((r) => r.userId === userId) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)) + .slice(0, limit) + .map((r) => ({ ...r })); + } + + async revoke(credentialId: string, at: string) { + const r = this.records.get(credentialId); + if (r && r.revokedAt === null) r.revokedAt = at; + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } +} diff --git a/factory/api/src/delegations/model.ts b/factory/api/src/delegations/model.ts new file mode 100644 index 000000000..ee1582358 --- /dev/null +++ b/factory/api/src/delegations/model.ts @@ -0,0 +1,47 @@ +// 委任の資格情報(#2384 の集約「委任の資格情報」)。人が画面で発行し、エージェントかクライアントへ渡す +import type { Idp } from "../accounts/model.ts"; + +// read は読み取りだけ、read_write は読み書き。どちらも人だけが確定できる操作は呼べない(I4) +export type DelegationScope = "read" | "read_write"; +export type DelegationState = "active" | "expired" | "revoked"; + +export const DELEGATION_SCOPES: readonly DelegationScope[] = ["read", "read_write"]; +// 期限は発行から 300〜3,600 秒(I1) +export const MIN_EXPIRES_SECONDS = 300; +export const MAX_EXPIRES_SECONDS = 3600; +export const MAX_CLIENT_NAME = 64; +// 一覧で返す数 +export const LIST_LIMIT = 100; +// 値の頭。Cognito の token と分ける(決定 8) +export const CREDENTIAL_PREFIX = "ndfd_"; + +export interface DelegationRecord { + credentialId: string; + userId: string; + idp: Idp; + clientName: string; + scope: DelegationScope; + // 秘密の SHA-256 の 16 進。平文は持たない(I3) + secretHash: string; + createdAt: string; + expiresAt: string; + // null は取り消していない。取り消しで 1 度だけ書く + revokedAt: string | null; +} + +export function stateOf(record: DelegationRecord, now: string): DelegationState { + if (record.revokedAt !== null) return "revoked"; + return record.expiresAt <= now ? "expired" : "active"; +} + +export function delegationView(record: DelegationRecord, now: string) { + return { + credential_id: record.credentialId, + client_name: record.clientName, + scope: record.scope, + created_at: record.createdAt, + expires_at: record.expiresAt, + revoked_at: record.revokedAt, + state: stateOf(record, now), + }; +} diff --git a/factory/api/src/delegations/service.ts b/factory/api/src/delegations/service.ts new file mode 100644 index 000000000..a2844788a --- /dev/null +++ b/factory/api/src/delegations/service.ts @@ -0,0 +1,148 @@ +// 委任の管理(#2384 の F2・F3)。委任の資格情報の発行・一覧・取り消しと、値の照合。 +// 値は ndfd_<資格情報の ID>_<秘密> の不透明な値で、要求のたびに表を強い整合で読み、照合の結果をためない(決定 8) +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Caller, Idp } from "../accounts/model.ts"; +import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts"; +import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts"; +import { + CREDENTIAL_PREFIX, + DELEGATION_SCOPES, + LIST_LIMIT, + MAX_CLIENT_NAME, + MAX_EXPIRES_SECONDS, + MIN_EXPIRES_SECONDS, + delegationView, + stateOf, + type DelegationRecord, + type DelegationScope, +} from "./model.ts"; +import type { DelegationStore } from "./store.ts"; + +// RFC 4648 の base32 を小文字で、埋めの = を付けずに書く。16 バイトは 26 字 +const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"; +function base32(bytes: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const b of bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]; + return out; +} + +const VALUE = new RegExp(`^${CREDENTIAL_PREFIX}([a-z2-7]{26})_([A-Za-z0-9_-]{43})$`); +const sha256 = (secret: string) => createHash("sha256").update(secret, "utf8").digest("hex"); + +// 照合が通った委任の資格情報。人の直の操作と同じ Caller を組むための値と、範囲を持つ +export interface VerifiedDelegation { + userId: string; + idp: Idp; + credentialId: string; + scope: DelegationScope; +} + +export interface DelegationDeps { + store: DelegationStore; + now?: () => Date; + random?: (n: number) => Buffer; +} + +export class DelegationService { + private readonly now: () => Date; + private readonly random: (n: number) => Buffer; + + constructor(private readonly deps: DelegationDeps) { + this.now = deps.now ?? (() => new Date()); + this.random = deps.random ?? randomBytes; + } + + private iso(): string { + return this.now().toISOString(); + } + + // 値の照合(E2・E10)。形が違う・無い・秘密が違う・期限切れ・取り消し済みはどれも null(呼ぶ側が 401 にする) + async verify(value: string): Promise { + const m = VALUE.exec(value); + if (!m) return null; + let record: DelegationRecord | null; + try { + record = await this.deps.store.get(m[1]!); + } catch { + throw new ServiceUnavailableError("delegation_unavailable"); + } + if (!record) return null; + const given = Buffer.from(sha256(m[2]!), "hex"); + const saved = Buffer.from(record.secretHash, "hex"); + if (given.length !== saved.length || !timingSafeEqual(given, saved)) return null; + if (stateOf(record, this.iso()) !== "active") return null; + return { userId: record.userId, idp: record.idp, credentialId: record.credentialId, scope: record.scope }; + } + + // 発行(E1)。値は結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を null にする(I14) + async issue(caller: Caller, element: Record) { + const clientName = element.client_name; + if (typeof clientName !== "string" || clientName.trim() === "" || clientName.length > MAX_CLIENT_NAME) { + throw new InvalidRequestError(`クライアントの名前は 1〜${MAX_CLIENT_NAME} 字にしてください`); + } + const scope = element.scope; + if (!DELEGATION_SCOPES.includes(scope as DelegationScope)) throw new InvalidRequestError("範囲は read か read_write です"); + const seconds = element.expires_in_seconds; + if (!Number.isInteger(seconds) || (seconds as number) < MIN_EXPIRES_SECONDS || (seconds as number) > MAX_EXPIRES_SECONDS) { + throw new InvalidRequestError(`期限は ${MIN_EXPIRES_SECONDS}〜${MAX_EXPIRES_SECONDS} 秒にしてください`, "invalid_expires_in"); + } + const ref: IdempotencyRef = { userId: caller.userId, operationId: "createDelegation", key: parseIdempotencyKey(element.idempotency_key) }; + const now = this.now(); + const replayed = await this.replay(ref, now.toISOString()); + if (replayed) return replayed; + const credentialId = base32(this.random(16)); + const secret = this.random(32).toString("base64url"); + const record: DelegationRecord = { + credentialId, + userId: caller.userId, + idp: caller.idp, + clientName, + scope: scope as DelegationScope, + secretHash: sha256(secret), + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + (seconds as number) * 1000).toISOString(), + revokedAt: null, + }; + const created = await this.deps.store.create(record, claimOf(ref, credentialId, record.createdAt)); + if (created === "duplicate") { + const won = await this.replay(ref, now.toISOString()); + if (!won) throw new ServiceUnavailableError("delegation_unavailable"); + return won; + } + return { delegation: delegationView(record, record.createdAt), credential: `${CREDENTIAL_PREFIX}${credentialId}_${secret}` }; + } + + private async replay(ref: IdempotencyRef, now: string) { + const id = await this.deps.store.getIdempotent(ref, now); + const record = id ? await this.deps.store.get(id) : null; + return record ? { delegation: delegationView(record, now), credential: null } : null; + } + + // 自分が発行したものだけを、新しい順で返す + async list(caller: Caller) { + const now = this.iso(); + return (await this.deps.store.listByUser(caller.userId, LIST_LIMIT)).map((r) => delegationView(r, now)); + } + + // 取り消し(E9)。期限切れと取り消し済みは何も変えずに成功を返す。他人の資格情報と無い資格情報は同じ forbidden + async revoke(caller: Caller, credentialId: unknown) { + const record = typeof credentialId === "string" ? await this.deps.store.get(credentialId) : null; + if (!record || record.userId !== caller.userId) throw new ForbiddenError(); + const now = this.iso(); + if (stateOf(record, now) === "active") { + await this.deps.store.revoke(record.credentialId, now); + return delegationView({ ...record, revokedAt: now }, now); + } + return delegationView(record, now); + } +} diff --git a/factory/api/src/delegations/store.ts b/factory/api/src/delegations/store.ts new file mode 100644 index 000000000..504fd4249 --- /dev/null +++ b/factory/api/src/delegations/store.ts @@ -0,0 +1,129 @@ +// 委任の資格情報の表への読み書き(#2384 の「データ構造」)。区分の鍵は credential_id だけで、利用者の一覧は GSI で引く +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, GetCommand, QueryCommand, TransactWriteCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; +import type { DelegationRecord } from "./model.ts"; + +export interface DelegationStore { + // 強い整合で読む(取り消しを次の要求から効かせる。I2) + get(credentialId: string): Promise; + // 行が無いときだけ書く。idempotency があれば鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate + create(record: DelegationRecord, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate">; + // 利用者が発行した資格情報の新しい順 + listByUser(userId: string, limit: number): Promise; + // revokedAt が空のときだけ 1 度だけ書く。既に書いてあれば何もしない + revoke(credentialId: string, at: string): Promise; + getIdempotent(ref: IdempotencyRef, now: string): Promise; +} + +// GSI の名前(インフラが作る) +export const USER_INDEX = "user_id-created_at"; + +type Item = Record; + +function toItem(r: DelegationRecord): Item { + return { + credential_id: r.credentialId, + user_id: r.userId, + idp: r.idp, + client_name: r.clientName, + scope: r.scope, + secret_hash: r.secretHash, + created_at: r.createdAt, + expires_at: r.expiresAt, + ...(r.revokedAt === null ? {} : { revoked_at: r.revokedAt }), + }; +} + +function toRecord(i: Item): DelegationRecord { + return { + credentialId: String(i.credential_id), + userId: String(i.user_id), + idp: i.idp as DelegationRecord["idp"], + clientName: String(i.client_name), + scope: i.scope as DelegationRecord["scope"], + secretHash: String(i.secret_hash), + createdAt: String(i.created_at), + expiresAt: String(i.expires_at), + revokedAt: typeof i.revoked_at === "string" ? i.revoked_at : null, + }; +} + +const isCancelled = (err: unknown) => err instanceof Error && err.name === "TransactionCanceledException"; +const isConditionFailed = (err: unknown) => err instanceof Error && err.name === "ConditionalCheckFailedException"; + +export class DynamoDelegationStore implements DelegationStore { + private readonly doc: DynamoDBDocumentClient; + + constructor( + private readonly tableName: string, + client: DynamoDBClient = new DynamoDBClient({}), + ) { + this.doc = DynamoDBDocumentClient.from(client); + } + + async get(credentialId: string) { + const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: credentialId }, ConsistentRead: true })); + const item = out.Item as Item | undefined; + return item && typeof item.secret_hash === "string" ? toRecord(item) : null; + } + + async create(record: DelegationRecord, idempotency?: IdempotencyClaim) { + const items: Item[] = [{ Put: { TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(credential_id)" } }]; + if (idempotency) { + items.push({ + Put: { + TableName: this.tableName, + Item: { credential_id: idempotencyPk(idempotency), createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl }, + ConditionExpression: "attribute_not_exists(credential_id) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) }, + }, + }); + } + try { + await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); + return "created" as const; + } catch (err) { + // 資格情報の ID は 16 バイトの乱数で重ならないため、条件の失敗は冪等の鍵の行 + if (idempotency && isCancelled(err)) return "duplicate" as const; + throw err; + } + } + + async listByUser(userId: string, limit: number) { + const out = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + IndexName: USER_INDEX, + KeyConditionExpression: "user_id = :u", + ExpressionAttributeValues: { ":u": userId }, + ScanIndexForward: false, + Limit: limit, + }), + ); + return (out.Items ?? []).map((i) => toRecord(i as Item)); + } + + async revoke(credentialId: string, at: string) { + try { + await this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { credential_id: credentialId }, + UpdateExpression: "SET revoked_at = :at", + ConditionExpression: "attribute_exists(credential_id) AND attribute_not_exists(revoked_at)", + ExpressionAttributeValues: { ":at": at }, + }), + ); + } catch (err) { + if (!isConditionFailed(err)) throw err; + } + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: idempotencyPk(ref) }, ConsistentRead: true })); + const item = out.Item as Item | undefined; + return liveCreatedId(item ? { createdId: String(item.createdId), ttl: Number(item.ttl) } : null, now); + } +} diff --git a/factory/api/src/errors.ts b/factory/api/src/errors.ts index 4401619c9..79e6dd340 100644 --- a/factory/api/src/errors.ts +++ b/factory/api/src/errors.ts @@ -30,6 +30,20 @@ export class ForbiddenError extends ApiError { } } +// 委任の資格情報で、人だけが確定できる操作を呼んだ(#2384 の I4) +export class HumanOnlyError extends ApiError { + constructor() { + super(403, "human_only", "この操作は人が画面で確定します。委任の資格情報からは行えません"); + } +} + +// 範囲が読み取りだけの委任の資格情報で、書き込みの操作を呼んだ(#2384 の I5) +export class ScopeError extends ApiError { + constructor() { + super(403, "scope_insufficient", "読み取りだけの委任では、この操作を行えません"); + } +} + export class InvalidRequestError extends ApiError { constructor(message = "要求の形が正しくありません", code = "invalid_request", details?: Record) { super(400, code, message, details); diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index b313eb9be..6c7bdca01 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -1,14 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createTestApi, type TestApi } from "../testing/app.ts"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; import { createApiApp } from "./api.ts"; let api: TestApi; +let keys = 0; beforeEach(async () => { api = await createTestApi(); }); async function me(user: string) { - return (await (await api.call("GET", "/me", { user })).json()) as { + return (await (await api.op("getMe", { user })).json())[0] as { user_id: string; personal_account_id: string; accounts: { account_id: string; kind: string; plan: string; role: string; seat_type: string | null; execution_mode: string }[]; @@ -17,14 +18,20 @@ async function me(user: string) { async function team(owner: string, seat_type = "standard") { await me(owner); - const res = await api.call("POST", "/teams", { user: owner, body: { name: "チーム", seat_type } }); - expect(res.status).toBe(201); - return (await res.json()) as { account_id: string; seats: { user_id: string; seat_type: string }[] }; + const { status, body } = await firstResult(await api.op("createTeam", { user: owner, items: [{ name: "チーム", seat_type, idempotency_key: `k-${++keys}` }] })); + expect(status).toBe(200); + return body as { account_id: string; seats: { user_id: string; seat_type: string }[] }; } const seatsOf = (accountId: string) => api.store.listSeats(accountId); +const addSeat = (user: string, accountId: string, user_id: string, seat_type: string) => + api.op("addSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult); +const changeSeat = (user: string, accountId: string, user_id: string, seat_type: string) => + api.op("changeSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult); +const removeSeat = (user: string, accountId: string, user_id: string) => api.op("removeSeat", { user, accountId, query: { user_id: [user_id] } }).then(firstResult); +const getAccount = (user: string, id: string) => api.op("getAccount", { user, query: { account_id: [id] } }).then(firstResult); -describe("GET /me(E2、受け入れ条件 12・18、I24)", () => { +describe("getMe(E2、受け入れ条件 12・18、I24)", () => { it("初回は Free・local の個人のアカウントを 1 つ作る", async () => { const body = await me("u-1"); expect(body.accounts).toEqual([ @@ -43,27 +50,26 @@ describe("GET /me(E2、受け入れ条件 12・18、I24)", () => { api.store.beforeWrite = () => { throw new Error("down"); }; - const res = await api.call("GET", "/me", { user: "u-1" }); + const res = await api.op("getMe", { user: "u-1" }); expect(res.status).toBe(503); expect((await res.json()).error.code).toBe("account_unavailable"); api.store.beforeWrite = null; - expect((await api.call("GET", "/me", { user: "u-1" })).status).toBe(200); + expect((await api.op("getMe", { user: "u-1" })).status).toBe(200); }); it("席を持つチームが一覧に出る", async () => { const t = await team("owner"); await me("u-1"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "premium" } }); + await addSeat("owner", t.account_id, "u-1", "premium"); const body = await me("u-1"); expect(body.accounts[1]).toMatchObject({ account_id: t.account_id, kind: "team", plan: "team", role: "member", seat_type: "premium" }); }); }); -describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", () => { +describe("getAccount(受け入れ条件 5・6・8・18)", () => { it("個人のアカウントは上限と形態を返し、席を返さない", async () => { const { personal_account_id } = await me("u-1"); - const res = await api.call("GET", `/accounts/${personal_account_id}`, { user: "u-1" }); - expect(await res.json()).toEqual({ + expect((await getAccount("u-1", personal_account_id)).body).toEqual({ account_id: personal_account_id, kind: "personal", name: null, @@ -78,11 +84,9 @@ describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", () it("チームは席の種類で上限が決まり、managed のときだけ月の token を返す", async () => { const t = await team("owner", "premium"); - let res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" }); - expect((await res.json()).limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null }); - await api.call("PUT", `/accounts/${t.account_id}/execution-mode`, { user: "owner", body: { execution_mode: "managed" } }); - res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" }); - const body = await res.json(); + expect((await getAccount("owner", t.account_id)).body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null }); + await api.op("setExecutionMode", { user: "owner", items: [{ account_id: t.account_id, execution_mode: "managed" }] }); + const { body } = await getAccount("owner", t.account_id); expect(body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: 125_000_000 }); expect(body.seats).toEqual([{ user_id: "owner", idp: "google", seat_type: "premium", added_at: expect.any(String) }]); }); @@ -96,16 +100,17 @@ describe("他人のアカウント(受け入れ条件 13、I18)", () => { const targets = [other.personal_account_id, t.account_id, "00000000-0000-4000-8000-999999999999"]; const bodies = new Set(); for (const id of targets) { - for (const [method, path, body] of [ - ["GET", `/accounts/${id}`, undefined], - ["PUT", `/accounts/${id}/execution-mode`, { execution_mode: "byok" }], - ["POST", `/accounts/${id}/seats`, { user_id: "a", seat_type: "standard" }], - ["PATCH", `/accounts/${id}/seats/owner`, { seat_type: "premium" }], - ["DELETE", `/accounts/${id}/seats/owner`, undefined], + // アカウントそのものの操作は要素の forbidden、アカウントに属するものの操作は要求全体の 403。本文は同じ + for (const [name, call] of [ + ["getAccount", () => getAccount("a", id)], + ["setExecutionMode", () => api.op("setExecutionMode", { user: "a", items: [{ account_id: id, execution_mode: "byok" }] }).then(firstResult)], + ["addSeat", () => addSeat("a", id, "a", "standard")], + ["changeSeat", () => changeSeat("a", id, "owner", "premium")], + ["removeSeat", () => removeSeat("a", id, "owner")], ] as const) { - const res = await api.call(method, path, { user: "a", body }); - expect(res.status, `${method} ${path}`).toBe(403); - bodies.add(await res.text()); + const { status, body } = await call(); + expect(status, name).toBe(403); + bodies.add(JSON.stringify(body)); } } expect(bodies.size).toBe(1); @@ -120,25 +125,31 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () expect(t.seats).toEqual([expect.objectContaining({ user_id: "owner", seat_type: "premium" })]); }); + it("同じ冪等の鍵の 2 回目は 1 回目のチームを返し、チームは 1 つだけ(#2384 の I14)", async () => { + await me("owner"); + const create = () => api.op("createTeam", { user: "owner", items: [{ name: "チーム", seat_type: "standard", idempotency_key: "same" }] }).then(firstResult); + const first = await create(); + const second = await create(); + expect(second.body.account_id).toBe(first.body.account_id); + expect([...api.store.accounts.values()].filter((a) => a.kind === "team")).toHaveLength(1); + }); + it.each([ [{ name: "", seat_type: "standard" }], [{ name: "あ".repeat(65), seat_type: "standard" }], [{ name: "x", seat_type: "gold" }], ])("名前か席の種類が違えば 400: %j", async (body) => { await me("owner"); - expect((await api.call("POST", "/teams", { user: "owner", body })).status).toBe(400); + expect((await firstResult(await api.op("createTeam", { user: "owner", items: [{ ...body, idempotency_key: "k-400" }] }))).status).toBe(400); }); it("持ち主は席を足し、種類を変え、外せる。そのたびに履歴が 1 つ増える", async () => { const t = await team("owner"); await me("u-1"); const before = api.store.history.length; - let res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); - expect(res.status).toBe(201); - res = await api.call("PATCH", `/accounts/${t.account_id}/seats/u-1`, { user: "owner", body: { seat_type: "premium" } }); - expect(await res.json()).toMatchObject({ user_id: "u-1", seat_type: "premium" }); - res = await api.call("DELETE", `/accounts/${t.account_id}/seats/u-1`, { user: "owner" }); - expect(res.status).toBe(204); + expect((await addSeat("owner", t.account_id, "u-1", "standard")).status).toBe(200); + expect((await changeSeat("owner", t.account_id, "u-1", "premium")).body).toMatchObject({ user_id: "u-1", seat_type: "premium" }); + expect(await removeSeat("owner", t.account_id, "u-1")).toEqual({ status: 200, body: { removed: true } }); expect(api.store.history.slice(before).map((h) => h.type)).toEqual(["seat.added", "seat.changed", "seat.removed"]); expect(await seatsOf(t.account_id)).toHaveLength(1); }); @@ -147,16 +158,16 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () const t = await team("owner"); await me("member"); await me("u-2"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "member", "standard"); const seats = await seatsOf(t.account_id); const before = api.store.history.length; - for (const [method, path, body] of [ - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-2", seat_type: "standard" }], - ["PATCH", `/accounts/${t.account_id}/seats/owner`, { seat_type: "premium" }], - ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined], - ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }], - ] as const) { - expect((await api.call(method, path, { user: "member", body })).status).toBe(403); + for (const result of [ + await addSeat("member", t.account_id, "u-2", "standard"), + await changeSeat("member", t.account_id, "owner", "premium"), + await removeSeat("member", t.account_id, "owner"), + await changeSeat("member", t.account_id, "nobody", "premium"), + ]) { + expect(result.status).toBe(403); } expect(await seatsOf(t.account_id)).toEqual(seats); expect(api.store.history.length).toBe(before); @@ -165,20 +176,20 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () it("未ログイン → 422、既に席がある → 409、持ち主の席を外す → 409。席も履歴も変わらない", async () => { const t = await team("owner"); await me("u-1"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "u-1", "standard"); const seats = await seatsOf(t.account_id); const before = api.store.history.length; const cases = [ - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "never", seat_type: "standard" }, 422, "user_not_found"], - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-1", seat_type: "premium" }, 409, "seat_exists"], - ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined, 409, "owner_seat_protected"], - ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }, 404, "seat_not_found"], - ["DELETE", `/accounts/${t.account_id}/seats/nobody`, undefined, 404, "seat_not_found"], + [() => addSeat("owner", t.account_id, "never", "standard"), 422, "user_not_found"], + [() => addSeat("owner", t.account_id, "u-1", "premium"), 409, "seat_exists"], + [() => removeSeat("owner", t.account_id, "owner"), 409, "owner_seat_protected"], + [() => changeSeat("owner", t.account_id, "nobody", "premium"), 404, "seat_not_found"], + [() => removeSeat("owner", t.account_id, "nobody"), 404, "seat_not_found"], ] as const; - for (const [method, path, body, status, code] of cases) { - const res = await api.call(method, path, { user: "owner", body }); + for (const [call, status, code] of cases) { + const res = await call(); expect(res.status, code).toBe(status); - expect((await res.json()).error).toEqual({ code, message: expect.any(String) }); + expect(res.body.error).toEqual({ code, message: expect.any(String) }); } expect(await seatsOf(t.account_id)).toEqual(seats); expect(api.store.history.length).toBe(before); @@ -187,9 +198,9 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () it("個人のアカウントへの席の操作は 409 not_a_team", async () => { const { personal_account_id } = await me("u-1"); await me("u-2"); - const res = await api.call("POST", `/accounts/${personal_account_id}/seats`, { user: "u-1", body: { user_id: "u-2", seat_type: "standard" } }); + const res = await addSeat("u-1", personal_account_id, "u-2", "standard"); expect(res.status).toBe(409); - expect((await res.json()).error.code).toBe("not_a_team"); + expect(res.body.error.code).toBe("not_a_team"); }); it("書き込みが失敗したら席も履歴も増えない", async () => { @@ -199,7 +210,7 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () api.store.beforeWrite = () => { throw new Error("down"); }; - const res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); + const res = await addSeat("owner", t.account_id, "u-1", "standard"); expect(res.status).toBe(503); expect(api.store.history.length).toBe(before); expect(await seatsOf(t.account_id)).toHaveLength(1); @@ -208,14 +219,14 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { const put = (user: string, id: string, execution_mode: unknown) => - api.call("PUT", `/accounts/${id}/execution-mode`, { user, body: { execution_mode } }); + api.op("setExecutionMode", { user, items: [{ account_id: id, execution_mode }] }).then(firstResult); it("持ち主は形態を変えられ、履歴が 1 つ増える。同じ値なら増えない", async () => { const { personal_account_id: id } = await me("u-1"); const before = api.store.history.length; const res = await put("u-1", id, "byok"); expect(res.status).toBe(200); - expect((await res.json()).execution_mode).toBe("byok"); + expect(res.body.execution_mode).toBe("byok"); expect((await put("u-1", id, "byok")).status).toBe(200); expect(api.store.history.slice(before).map((h) => [h.type, h.before, h.after])).toEqual([["execution_mode.changed", "local", "byok"]]); }); @@ -225,7 +236,7 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { const before = api.store.history.length; const res = await put("u-1", id, "managed"); expect(res.status).toBe(409); - expect((await res.json()).error).toEqual({ + expect(res.body.error).toEqual({ code: "managed_not_allowed_on_free", message: "Free の料金プランでは managed(サービスの Bedrock)を選べません", }); @@ -236,16 +247,16 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { it("Pro なら managed を選べる", async () => { const { personal_account_id: id } = await me("u-1"); await api.service.setPlan(id, "pro"); - expect((await (await put("u-1", id, "managed")).json()).limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 }); + expect((await put("u-1", id, "managed")).body.limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 }); }); it("チームの持ち主でない席の利用者は 403 で、形態は変わらない", async () => { const t = await team("owner"); await me("member"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "member", "standard"); const res = await put("member", t.account_id, "byok"); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }); + expect(res.body).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }); expect(api.store.accounts.get(t.account_id)?.executionMode).toBe("local"); }); diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index 89fc9efe5..d6b306326 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -9,10 +9,19 @@ import { requireCaller, type AuthDeps, type AuthEnv } from "../auth/resolve.ts"; import { registerMe } from "../routes/me.ts"; import { registerTeams } from "../routes/teams.ts"; import { registerAccounts } from "../routes/accounts.ts"; +import { registerDelegations } from "../routes/delegations.ts"; +import { DelegationService } from "../delegations/service.ts"; +import { DynamoDelegationStore } from "../delegations/store.ts"; +import { recordOperations, requestIdOf } from "../audit/middleware.ts"; +import { DynamoAuditLog } from "../audit/dynamo-log.ts"; +import { StructuredLogAudit } from "../audit/structured-log.ts"; +import { registerMcp } from "../mcp/server.ts"; import { registerFactory, type FactoryDeps } from "../routes/factory.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; export interface LogEntry { + // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) + request_id: string; user_id: string | null; method: string; path: string; @@ -44,8 +53,10 @@ export function createApiApp( ): Hono { const app = new Hono(); app.use("*", async (c, next) => { + c.set("requestId", requestIdOf(c)); await next(); log({ + request_id: c.get("requestId")!, user_id: c.get("caller")?.userId ?? null, method: c.req.method, path: c.req.path, @@ -61,6 +72,8 @@ export function createApiApp( allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"], }), ); + // 操作の記録は認証より外に置き、401 で断った要求も記録する(I7) + app.use("*", recordOperations(() => deps().audit, (line) => console.log(JSON.stringify(line)))); app.use("*", requireCaller(deps)); app.onError((err, c) => { const known = err instanceof ApiError ? err : new AccountUnavailableError(); @@ -69,7 +82,10 @@ export function createApiApp( registerMe(app); registerTeams(app); registerAccounts(app); + registerDelegations(app); if (factory) registerFactory(app, factory); + // MCP の受け口。依存は同じアプリへの内部の要求だけ(#2384 の I15) + registerMcp(app, (path, init, env) => app.request(path, init, env), webOrigin); return app; } @@ -89,6 +105,10 @@ export function depsFromEnv(): AuthDeps { clientId: requireEnv("COGNITO_CLIENT_ID"), }), accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }), + // 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする + delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null, + // 操作の記録の表(インフラが AUDIT_TABLE_NAME で渡す)。無い間は記録を構造化ログへ出す + audit: process.env.AUDIT_TABLE_NAME ? new DynamoAuditLog(process.env.AUDIT_TABLE_NAME) : new StructuredLogAudit(), }; return cached; } diff --git a/factory/api/src/idempotency.ts b/factory/api/src/idempotency.ts new file mode 100644 index 000000000..9fa7857fe --- /dev/null +++ b/factory/api/src/idempotency.ts @@ -0,0 +1,41 @@ +// 作成の冪等の鍵(#2384 の決定 4・不変条件 I14)。新しい識別子を作る作成で、同じ利用者・同じ操作・同じ鍵の +// 24 時間以内の作成を 1 件にする。鍵の行は作るものと同じ表へ同じトランザクションで書く +import { InvalidRequestError } from "./errors.ts"; + +export const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60; +const KEY_PATTERN = /^[0-9A-Za-z-]{1,64}$/; + +// 鍵の行を引くための 3 つの値 +export interface IdempotencyRef { + userId: string; + operationId: string; + key: string; +} + +// 鍵の行に書く値。ttl は作った日時の 24 時間後(UNIX 秒) +export interface IdempotencyClaim extends IdempotencyRef { + createdId: string; + createdAt: string; + ttl: number; +} + +export function parseIdempotencyKey(raw: unknown): string { + if (typeof raw !== "string" || !KEY_PATTERN.test(raw)) { + throw new InvalidRequestError("作成の冪等の鍵は 1〜64 字の英数字とハイフンです", "invalid_idempotency_key"); + } + return raw; +} + +export const idempotencyPk = (ref: IdempotencyRef) => `IDEMP#${ref.userId}#${ref.operationId}#${ref.key}`; + +export const epochSeconds = (iso: string) => Math.floor(Date.parse(iso) / 1000); + +export function claimOf(ref: IdempotencyRef, createdId: string, createdAt: string): IdempotencyClaim { + return { ...ref, createdId, createdAt, ttl: epochSeconds(createdAt) + IDEMPOTENCY_TTL_SECONDS }; +} + +// 期限は TTL の削除に頼らず、読むたびに今の時刻と比べる(DynamoDB の TTL は期限を過ぎた行をすぐには消さない) +export function liveCreatedId(row: { createdId: string; ttl: number } | null, nowIso: string): string | null { + if (!row) return null; + return row.ttl > epochSeconds(nowIso) ? row.createdId : null; +} diff --git a/factory/api/src/mcp/mcp.test.ts b/factory/api/src/mcp/mcp.test.ts new file mode 100644 index 000000000..afa9e92c4 --- /dev/null +++ b/factory/api/src/mcp/mcp.test.ts @@ -0,0 +1,116 @@ +// MCP の受け口(#2384 の受け入れ条件 2・3・17・18、不変条件 I11・I15・I16) +import { Hono } from "hono"; +import { beforeEach, describe, expect, it } from "vitest"; +import { OPERATIONS } from "@ndf-factory/contracts/operations"; +import type { AuthEnv } from "../auth/resolve.ts"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; +import { registerMcp, tools, type AppRequest } from "./server.ts"; + +let api: TestApi; +let credential: string; +let account: string; +let id = 0; + +beforeEach(async () => { + api = await createTestApi({ factory: true }); + account = (await (await api.op("getMe", { user: "u-1" })).json())[0].personal_account_id; + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: "c", scope: "read_write", expires_in_seconds: 3600, idempotency_key: "m" }] }), + ); + credential = issued.body.credential; +}); + +async function rpc(method: string, params: unknown, authorization = `Bearer ${credential}`, headers: Record = {}) { + const res = await api.app.request("/mcp", { + method: "POST", + headers: { Authorization: authorization, "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }), + }); + return { status: res.status, body: (await res.json().catch(() => null)) as any }; +} + +const callTool = async (name: string, args: Record) => (await rpc("tools/call", { name, arguments: args })).body.result; + +describe("ツールと契約の対応(受け入れ条件 2、I11)", () => { + it("tools/list のツールの名前の集合が、契約の利用者向けの操作と一致する", async () => { + const { status, body } = await rpc("tools/list", {}); + expect(status).toBe(200); + expect(body.result.tools.map((t: { name: string }) => t.name).sort()).toEqual(OPERATIONS.map((o) => o.operationId).sort()); + }); + + it("人だけが確定できるツールは説明の頭で断られることを示し、入力は操作の形を持つ", () => { + const byName = new Map(tools().map((t) => [t.name, t])); + expect(byName.get("answerLoopPending")!.description).toMatch(/^人だけが確定できる/); + expect(byName.get("startSprintRun")!.inputSchema.required).toEqual(["account_id", "items"]); + expect(byName.get("getSprintRunDetail")!.inputSchema.properties.sprint_run_id).toMatchObject({ type: "array", maxItems: 100 }); + expect(byName.get("getMe")!.inputSchema.required).toEqual([]); + }); +}); + +describe("ツールの呼び出し(受け入れ条件 3・17・18、I15・I16)", () => { + it("API の経路と同じ結果を返し、操作の記録に mcp として残る。tools/list は記録しない", async () => { + const direct = await (await api.op("listSprintRuns", { authorization: `Bearer ${credential}`, accountId: account })).json(); + const before = api.audit.items.length; + await rpc("tools/list", {}); + expect(api.audit.items.length).toBe(before); + const r = await callTool("listSprintRuns", { account_id: account }); + expect(r.structuredContent).toEqual({ status: 200, body: direct }); + expect(r.isError).toBe(false); + expect(JSON.parse(r.content[0].text)).toEqual(r.structuredContent); + expect(api.audit.items.at(-1)).toMatchObject({ operation_id: "listSprintRuns", via: "mcp", user_id: "u-1", outcome: "ok" }); + expect(api.audit.items.length).toBe(before + 1); + }); + + it("人だけが確定できるツールは 403 human_only を返し、答えも状態も保存しない", async () => { + const r = await callTool("answerLoopPending", { account_id: account, items: [{ loop_id: "l", issue: 1, decision: "approved" }] }); + expect(r.isError).toBe(true); + expect(r.structuredContent).toMatchObject({ status: 403, body: { error: { code: "human_only" } } }); + expect(api.h!.table.loops.size).toBe(0); + }); + + it("承認の依頼の材料(スプリントの詳細)は読める。要素の結果はそのまま返る", async () => { + const r = await callTool("getSprintRunDetail", { account_id: account, sprint_run_id: ["sr-none"] }); + expect(r.structuredContent.status).toBe(200); + expect(r.structuredContent.body[0]).toMatchObject({ ok: false, error: { code: "forbidden" } }); + }); + + it("アカウントが要るツールに account_id が無ければ 400 で、アプリへ送らない", async () => { + const r = await callTool("listSprintRuns", {}); + expect(r.structuredContent.status).toBe(400); + }); + + it("受け口が受け取る依存はアプリへの要求の関数だけで、ツールの呼び出しはすべてそこを通る", async () => { + const seen: string[] = []; + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("caller", { userId: "u", idp: "google", delegation: { credentialId: "x", scope: "read" }, via: "api" }); + await next(); + }); + const call: AppRequest = async (path, init) => { + seen.push(`${init.method} ${path}`); + return Response.json([]); + }; + registerMcp(app, call, () => null); + const res = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer ndfd_x", "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "listLoops", arguments: { account_id: "a", repository_id: 1 } } }), + }); + expect(res.status).toBe(200); + expect(seen).toEqual(["GET /accounts/a/loops?repository_id=1"]); + }); +}); + +describe("受け口の資格情報(決定 7)", () => { + it("Cognito の token は 401、資格情報が無ければ 401", async () => { + expect((await rpc("tools/list", {}, await api.tokens.bearer("u-1"))).status).toBe(401); + expect((await rpc("tools/list", {}, "")).status).toBe(401); + }); + + it("画面と違うオリジンからの要求は 403、GET と DELETE は 405", async () => { + expect((await rpc("tools/list", {}, `Bearer ${credential}`, { Origin: "https://evil.example.com" })).status).toBe(403); + for (const method of ["GET", "DELETE"]) { + expect((await api.app.request("/mcp", { method, headers: { Authorization: `Bearer ${credential}` } })).status).toBe(405); + } + }); +}); diff --git a/factory/api/src/mcp/server.ts b/factory/api/src/mcp/server.ts new file mode 100644 index 000000000..40c102131 --- /dev/null +++ b/factory/api/src/mcp/server.ts @@ -0,0 +1,93 @@ +// MCP の受け口(#2384 の F4・決定 6・7・14)。POST /mcp で MCP の要求を受け、操作の表から作ったツールの呼び出しを、 +// 同じアプリの経路へ内部の要求として送る。認証・範囲の照合・要素の検査・認可・記録は画面と API と同じ処理を通る(I15)。 +// 状態を持たず、要求ごとに JSON の 1 応答で返す。委任の資格情報だけを受ける +import type { Hono } from "hono"; +import { Server, WebStandardStreamableHTTPServerTransport, type Tool } from "@modelcontextprotocol/server"; +import { MAX_ITEMS, OPERATIONS, findOperation, operationUrl, type Operation } from "@ndf-factory/contracts/operations"; +import { MCP_INTERNAL, type AuthEnv } from "../auth/resolve.ts"; +import { AuthError, ForbiddenError } from "../errors.ts"; +import { MCP_PATH } from "../audit/middleware.ts"; + +// アプリへ内部の要求を送る関数(Hono の app.request)。MCP の受け口が受け取る依存はこれだけ +export type AppRequest = (path: string, init: RequestInit, env: Record) => Response | Promise; + +export const SERVER_INFO = { name: "ndf-factory", version: "0.1.0" } as const; + +const HUMAN_NOTE = "人だけが確定できる。MCP からは常に断られる。画面で行う。"; + +const BODY_KINDS = new Set(["create", "update", "action"]); + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { type: "object"; properties: Record; required: string[] }; +} + +// ツールの定義を操作の表から作る。手で書いたツールの定義を持たない(契約の表の「MCP のツール」) +export function toolOf(op: Operation): ToolDefinition { + const properties: Record = {}; + const required: string[] = []; + if (op.path.includes("{accountId}")) { + properties.account_id = { type: "string", description: "アカウントの ID" }; + required.push("account_id"); + } + if (BODY_KINDS.has(op.kind)) { + properties.items = { type: "array", minItems: 1, maxItems: MAX_ITEMS, items: op.itemSchema ?? { type: "object" }, description: "要素の配列。結果は同じ順に返る" }; + required.push("items"); + } else { + for (const p of op.parameters) { + properties[p.name] = { ...(p.schema as object), ...(p.description ? { description: p.description } : {}) }; + if (p.required) required.push(p.name); + } + } + const text = [op.summary, op.description].filter((s) => s !== "").join("\n"); + return { name: op.operationId, description: op.access === "human" ? `${HUMAN_NOTE}\n${text}` : text, inputSchema: { type: "object", properties, required } }; +} + +export const tools = (): ToolDefinition[] => OPERATIONS.map((op) => toolOf(op as Operation)); + +function result(status: number, body: unknown) { + const value = { status, body }; + return { content: [{ type: "text" as const, text: JSON.stringify(value) }], structuredContent: value, isError: status >= 400 }; +} + +// ツールの入力から経路・query・本文を組み、Authorization をそのまま付けて内部の要求として送る +async function callTool(call: AppRequest, authorization: string, name: string, args: Record) { + const op = findOperation(name); + if (!op) return result(400, { error: { code: "unknown_tool", message: `${name} というツールはありません` } }); + const accountId = typeof args.account_id === "string" ? args.account_id : null; + if (op.path.includes("{accountId}") && accountId === null) return result(400, { error: { code: "invalid_request", message: "account_id が要ります" } }); + const query: Record = {}; + if (!BODY_KINDS.has(op.kind)) for (const p of op.parameters) if (args[p.name] !== undefined) query[p.name] = args[p.name]; + const hasBody = BODY_KINDS.has(op.kind); + const res = await call( + operationUrl(op, accountId, query), + { + method: op.method, + headers: { Authorization: authorization, ...(hasBody ? { "Content-Type": "application/json" } : {}) }, + body: hasBody ? JSON.stringify(args.items ?? null) : undefined, + }, + { [MCP_INTERNAL]: true }, + ); + return result(res.status, await res.json().catch(() => null)); +} + +export function registerMcp(app: Hono, call: AppRequest, webOrigin: () => string | null): void { + app.post(MCP_PATH, async (c) => { + // 委任の資格情報だけを受ける(Cognito の token は 401) + if (!c.get("caller").delegation) throw new AuthError(); + // ブラウザからの要求は画面のオリジンだけを受ける(Streamable HTTP の Origin の検査) + const origin = c.req.header("Origin"); + if (origin !== undefined && origin !== webOrigin()) throw new ForbiddenError(); + const authorization = c.req.header("Authorization")!; + const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); + // 定義は契約から作った JSON Schema(JSON の値だけを持つ) + server.setRequestHandler("tools/list", async () => ({ tools: tools() as unknown as Tool[] })); + server.setRequestHandler("tools/call", async (req) => callTool(call, authorization, req.params.name, (req.params.arguments ?? {}) as Record)); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + return transport.handleRequest(c.req.raw); + }); + // 状態を持たないため、GET のストリームとセッションの終わりは受けない + for (const method of ["get", "delete"] as const) app[method](MCP_PATH, (c) => c.body(null, 405)); +} diff --git a/factory/api/src/routes/accounts.ts b/factory/api/src/routes/accounts.ts index d08c50839..30b594bb5 100644 --- a/factory/api/src/routes/accounts.ts +++ b/factory/api/src/routes/accounts.ts @@ -1,57 +1,69 @@ -import type { Hono } from "hono"; +import type { Context, Hono } from "hono"; import { ForbiddenError, InvalidRequestError } from "../errors.ts"; -import { isExecutionMode, isSeatType } from "../accounts/model.ts"; +import { isExecutionMode, isSeatType, type AccountContext } from "../accounts/model.ts"; import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; -import { readJson } from "./body.ts"; +import { registerOperation } from "./operation.ts"; import { accountDetail, seatView } from "./views.ts"; const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; +function userIdOf(raw: unknown): string { + if (typeof raw !== "string" || raw === "" || raw.length > 128) throw new InvalidRequestError("利用者の ID の形ではありません"); + return raw; +} + +// アカウントそのものの操作は、要素がアカウントのため要素ごとに解決する(#2384 の決定 2)。解決できない要素は forbidden +function accountOf(c: Context, raw: unknown): Promise { + if (typeof raw !== "string" || raw === "") throw new ForbiddenError(); + return resolveFromRequest(c, raw); +} + export function registerAccounts(app: Hono): void { - app.get("/accounts/:accountId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const accounts = c.get("auth").accounts; - const account = await accounts.getAccount(ctx.accountId); - if (!account) throw new ForbiddenError(); - const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; - return c.json(accountDetail(ctx, account, seats)); + registerOperation(app, "getAccount", { + run: async (e, _ctx, _s, c) => { + const ctx = await accountOf(c, e.account_id); + const accounts = c.get("auth").accounts; + const account = await accounts.getAccount(ctx.accountId); + if (!account) throw new ForbiddenError(); + const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; + return accountDetail(ctx, account, seats); + }, }); - app.put("/accounts/:accountId/execution-mode", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (!isExecutionMode(body.execution_mode)) { - throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです"); - } - const accounts = c.get("auth").accounts; - const account = await accounts.setExecutionMode(ctx, body.execution_mode); - const next = { ...ctx, executionMode: account.executionMode, plan: account.plan }; - const seats = next.kind === "team" ? await accounts.listSeats(next) : null; - return c.json(accountDetail(next, account, seats)); + registerOperation(app, "setExecutionMode", { + run: async (e, _ctx, _s, c) => { + const ctx = await accountOf(c, e.account_id); + if (!isExecutionMode(e.execution_mode)) { + throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです"); + } + const accounts = c.get("auth").accounts; + const account = await accounts.setExecutionMode(ctx, e.execution_mode); + const next = { ...ctx, executionMode: account.executionMode, plan: account.plan }; + const seats = next.kind === "team" ? await accounts.listSeats(next) : null; + return accountDetail(next, account, seats); + }, }); - app.post("/accounts/:accountId/seats", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (typeof body.user_id !== "string" || body.user_id === "" || body.user_id.length > 128) { - throw new InvalidRequestError("利用者の ID の形ではありません"); - } - if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - const seat = await c.get("auth").accounts.addSeat(ctx, body.user_id, body.seat_type); - return c.json(seatView(seat), 201); + registerOperation(app, "addSeat", { + run: async (e, ctx, _s, c) => { + const userId = userIdOf(e.user_id); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, e.seat_type)); + }, }); - app.patch("/accounts/:accountId/seats/:userId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - const seat = await c.get("auth").accounts.changeSeat(ctx, c.req.param("userId"), body.seat_type); - return c.json(seatView(seat)); + registerOperation(app, "changeSeat", { + run: async (e, ctx, _s, c) => { + const userId = userIdOf(e.user_id); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, e.seat_type)); + }, }); - app.delete("/accounts/:accountId/seats/:userId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - await c.get("auth").accounts.removeSeat(ctx, c.req.param("userId")); - return c.body(null, 204); + registerOperation(app, "removeSeat", { + run: async (e, ctx, _s, c) => { + await c.get("auth").accounts.removeSeat(ctx!, userIdOf(e.user_id)); + return { removed: true }; + }, }); } diff --git a/factory/api/src/routes/body.ts b/factory/api/src/routes/body.ts deleted file mode 100644 index a32bd9983..000000000 --- a/factory/api/src/routes/body.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Context } from "hono"; -import { InvalidRequestError } from "../errors.ts"; - -export async function readJson(c: Context): Promise> { - let body: unknown; - try { - body = await c.req.json(); - } catch { - throw new InvalidRequestError(); - } - if (typeof body !== "object" || body === null || Array.isArray(body)) throw new InvalidRequestError(); - return body as Record; -} diff --git a/factory/api/src/routes/delegations.ts b/factory/api/src/routes/delegations.ts new file mode 100644 index 000000000..c6bf1c2a4 --- /dev/null +++ b/factory/api/src/routes/delegations.ts @@ -0,0 +1,19 @@ +// エージェントへの委任の操作(openapi/delegations.yaml。#2384 の F2)。委任は利用者に属し、アカウントに属さない。 +// 発行と取り消しは人だけが確定でき(扱い human)、委任の資格情報からは操作の登録の層が断る +import type { Context, Hono } from "hono"; +import type { AuthEnv } from "../auth/resolve.ts"; +import type { DelegationService } from "../delegations/service.ts"; +import { ServiceUnavailableError } from "../errors.ts"; +import { registerList, registerOperation } from "./operation.ts"; + +function service(c: Context): DelegationService { + const d = c.get("auth").delegations; + if (!d) throw new ServiceUnavailableError("delegation_unavailable"); + return d; +} + +export function registerDelegations(app: Hono): void { + registerList(app, "listDelegations", (c) => service(c).list(c.get("caller"))); + registerOperation(app, "createDelegation", { run: (e, _ctx, _s, c) => service(c).issue(c.get("caller"), e) }); + registerOperation(app, "revokeDelegation", { run: (e, _ctx, _s, c) => service(c).revoke(c.get("caller"), e.credential_id) }); +} diff --git a/factory/api/src/routes/elements.test.ts b/factory/api/src/routes/elements.test.ts new file mode 100644 index 000000000..4bc2712e6 --- /dev/null +++ b/factory/api/src/routes/elements.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { ConflictError } from "../errors.ts"; +import { runElements } from "./elements.ts"; + +describe("要素ごとの実行(#2384 の決定 3)", () => { + it("保存の不調などの例外もその要素の 503 にし、ほかの要素の成功の結果を残す", async () => { + let done = 0; + const results = await runElements([1, 2, 3], async (n) => { + if (n === 2) throw new Error("ProvisionedThroughputExceeded"); + if (n === 3) throw new ConflictError("loop_busy", "busy"); + await new Promise((r) => setTimeout(r, 5)); + done++; + return n; + }); + expect(done).toBe(1); + expect(results[0]).toEqual({ ok: true, value: 1 }); + expect(results[1]).toMatchObject({ ok: false, status: 503 }); + expect(results[2]).toMatchObject({ ok: false, status: 409, error: { code: "loop_busy" } }); + }); +}); diff --git a/factory/api/src/routes/elements.ts b/factory/api/src/routes/elements.ts new file mode 100644 index 000000000..c54aefaeb --- /dev/null +++ b/factory/api/src/routes/elements.ts @@ -0,0 +1,37 @@ +// 要素ごとの実行(#2384 の決定 3・不変条件 I13)。要素を並べる数 10 で順にユースケースへ渡し、 +// ApiError を要素の結果に包む。応答は入力と同じ順・同じ数で、失敗の要素はその要素のデータを持たない +import { AccountUnavailableError, ApiError } from "../errors.ts"; + +// 要素の読み出しを並べる数の上限(一覧の行の読み出しと同じ値) +export const ELEMENT_CONCURRENCY = 10; + +export type ElementResult = + | { ok: true; value: unknown } + | { ok: false; status: number; error: { code: string; message: string; details?: Record } }; + +export async function mapLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]!, i); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +// ApiError でない失敗(保存の不調など)も、要求全体でなくその要素の 503 にする(決定 3)。1 要素の失敗で +// 全体を投げると、ほかの要素の書き込みが進んだまま成功の結果(1 度しか返せない委任の値など)が失われる。 +// 503 の本文は要求全体の失敗(handlers/api.ts の onError)と同じにする +export function runElements(elements: E[], fn: (element: E) => Promise): Promise { + return mapLimit(elements, ELEMENT_CONCURRENCY, async (element) => { + try { + return { ok: true as const, value: await fn(element) }; + } catch (err) { + const known = err instanceof ApiError ? err : new AccountUnavailableError(); + return { ok: false as const, status: known.status, error: known.body().error }; + } + }); +} diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index 60de82223..eea325c60 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -9,7 +9,8 @@ import { CANDIDATE_FALLBACK_NOTE, type CandidateModel } from "../runs/candidates import { LoopControl } from "../runs/loops.ts"; import { createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createTokenKit, type TokenKit } from "../testing/tokens.ts"; -import type { TestResponse } from "../testing/app.ts"; +import type { OperationId } from "@ndf-factory/contracts/operations"; +import { firstResult, opRequest, type OpOptions, type TestResponse } from "../testing/app.ts"; const API_KEY = "sk-ant-test-SECRET-VALUE-0001"; @@ -52,8 +53,17 @@ async function call(method: string, path: string, user: string, body?: unknown): return res; } +// 操作を呼び、要素 1 個の結果を状態と本文で読む(#2384) +async function op(operationId: OperationId, user: string, opts: Omit = {}): Promise<{ status: number; body: any }> { + const { method, path, body } = opRequest(operationId, opts); + return firstResult(await call(method, path, user, body)); +} + +let keys = 0; +const idem = () => `k-${++keys}`; + async function personal(user: string): Promise { - return ((await (await call("GET", "/me", user)).json()) as { personal_account_id: string }).personal_account_id; + return (await op("getMe", user)).body.personal_account_id; } beforeEach(async () => { @@ -88,97 +98,172 @@ beforeEach(async () => { }); async function candidates(accountId: string, user = "u-1") { - const url = (await (await call("GET", `/accounts/${accountId}/github/authorize-url`, user)).json()).url as string; + const url = (await op("getAuthorizeUrl", user, { accountId })).body.url as string; const state = new URL(url).searchParams.get("state"); - return call("POST", `/accounts/${accountId}/github/candidates`, user, { code: "code-1", state }); + return op("postRepositoryCandidates", user, { accountId, items: [{ code: "code-1", state }] }); } async function register(accountId: string, repositoryId = 101) { - const { proof } = await (await candidates(accountId)).json(); - return call("POST", `/accounts/${accountId}/repositories`, "u-1", { proof, repository_id: repositoryId }); + const { proof } = (await candidates(accountId)).body; + return op("registerRepository", "u-1", { accountId, items: [{ proof, repository_id: repositoryId }] }); } describe("取り込み(受け入れ条件 1〜5、I32・I33・I36)", () => { it("導入の案内と、利用者の届くリポジトリだけの候補が出る(AC1)", async () => { const acc = await personal("u-1"); - expect((await (await call("GET", `/accounts/${acc}/github/install-url`, "u-1")).json()).url).toContain("/installations/new"); - const body = await (await candidates(acc)).json(); + expect((await op("getInstallUrl", "u-1", { accountId: acc })).body.url).toContain("/installations/new"); + const { body } = await candidates(acc); expect(body.candidates.map((c: { repository_id: number }) => c.repository_id).sort()).toEqual([101, 102]); }); it("state の利用者が違えば 400(決定 12)", async () => { const acc = await personal("u-1"); - const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", { code: "code-1", state: "x" }); + expect((await op("postRepositoryCandidates", "u-1", { accountId: acc, items: [{ code: "code-1", state: "x" }] })).status).toBe(400); + }); + + it("鍵を持たない操作に 2 個の要素を送ると、どれも処理せずに 400 duplicate_key", async () => { + const acc = await personal("u-1"); + const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", [{ code: "code-1", state: "x" }, { code: "code-1", state: "y" }]); expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("duplicate_key"); }); it("証明に無い ID・導入が届かない登録は 403 で行が増えない(AC2・I33)", async () => { const acc = await personal("u-1"); - const { proof } = await (await candidates(acc)).json(); - expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 201 })).status).toBe(403); + const { proof } = (await candidates(acc)).body; + expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 201 }] })).status).toBe(403); h.github.installations.get(9)!.delete(102); - expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 102 })).status).toBe(403); + expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 102 }] })).status).toBe(403); expect(h.table.registrations.size).toBe(0); }); it("同じリポジトリの 2 回目と同時の 2 本は 409 で 1 行(AC3・I32)", async () => { const acc = await personal("u-1"); - expect((await register(acc)).status).toBe(201); + expect((await register(acc)).status).toBe(200); expect((await register(acc)).status).toBe(409); - const { proof } = await (await candidates(acc)).json(); - const both = await Promise.all([102, 102].map((id) => call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: id }))); - expect(both.map((r) => r.status).sort()).toEqual([201, 409]); + const { proof } = (await candidates(acc)).body; + const both = await Promise.all([102, 102].map((id) => op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: id }] }))); + expect(both.map((r) => r.status).sort()).toEqual([200, 409]); expect(h.table.registrations.size).toBe(2); }); - it("終わりの状態でない実行があると外せず 409、終われば 204(AC4・I36)", async () => { + it("終わりの状態でない実行があると外せず 409、終われば外せる(AC4・I36)", async () => { const acc = await personal("u-1"); await register(acc); h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }]); - const started = await call("POST", `/accounts/${acc}/sprint-runs`, "u-1", { repository_id: 101, issues: [1] }); - expect(started.status).toBe(201); - const { sprint_run_id } = await started.json(); + const started = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: idem() }] }); + expect(started.status).toBe(200); + const { sprint_run_id } = started.body; const list = await (await call("GET", `/accounts/${acc}/repositories`, "u-1")).json(); - expect(list.repositories[0].active_sprint_runs).toBe(1); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(409); - expect((await call("POST", `/accounts/${acc}/sprint-runs/${sprint_run_id}/cancel`, "u-1")).status).toBe(200); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(204); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(404); + expect(list[0].active_sprint_runs).toBe(1); + const remove = () => op("removeRepository", "u-1", { accountId: acc, query: { repository_id: [101] } }); + expect((await remove()).status).toBe(409); + expect((await op("cancelSprintRun", "u-1", { accountId: acc, items: [{ sprint_run_id }] })).status).toBe(200); + expect(await remove()).toEqual({ status: 200, body: { removed: true } }); + expect((await remove()).status).toBe(404); }); it("他人のアカウントの一覧・登録・外しは #2170 と同じ 403 の本文(AC5)", async () => { const acc = await personal("u-1"); await personal("u-2"); const forbidden = { error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }; - for (const [method, path, body] of [ - ["GET", `/accounts/${acc}/repositories`, undefined], - ["POST", `/accounts/${acc}/repositories`, { proof: "x", repository_id: 101 }], - ["DELETE", `/accounts/${acc}/repositories/101`, undefined], - ] as const) { - const res = await call(method, path, "u-2", body); - expect(res.status).toBe(403); - expect(await res.json()).toEqual(forbidden); + for (const result of [ + await op("listRepositories", "u-2", { accountId: acc }), + await op("registerRepository", "u-2", { accountId: acc, items: [{ proof: "x", repository_id: 101 }] }), + await op("removeRepository", "u-2", { accountId: acc, query: { repository_id: [101] } }), + ]) { + expect(result).toEqual({ status: 403, body: forbidden }); } }); }); +describe("作成の冪等の鍵(#2384 の受け入れ条件 9・I14)", () => { + async function ready() { + const acc = await personal("u-1"); + await register(acc); + h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }, { number: 2, title: "b" }]); + return acc; + } + + it("同じ鍵の 2 回目は 1 件だけ作り、同じ識別子を返す", async () => { + const acc = await ready(); + const item = { repository_id: 101, issues: [1], idempotency_key: "same-key" }; + const first = await op("startSprintRun", "u-1", { accountId: acc, items: [item] }); + const second = await op("startSprintRun", "u-1", { accountId: acc, items: [item] }); + expect(second.body.sprint_run_id).toBe(first.body.sprint_run_id); + expect(h.table.runs.size).toBe(1); + }); + + it("違う作成の操作へ同じ鍵を送ると、それぞれ作る", async () => { + const acc = await ready(); + await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "shared" }] }); + const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "shared" }] }); + expect(loop.status).toBe(200); + expect(h.table.loops.size).toBe(1); + expect(h.table.runs.size).toBeGreaterThanOrEqual(1); + }); + + it("鍵の行の期限(24 時間)を過ぎた同じ鍵は、行が残っていても新しく作る", async () => { + const acc = await ready(); + const first = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "old" }] }); + // 1 本目を終わらせ、同時の実行の数の上限に当たらないようにする + await h.table.finishRun(first.body.sprint_run_id, "done", null, h.clock.now.toISOString()); + h.clock.now = new Date(h.clock.now.getTime() + 24 * 60 * 60 * 1000 + 1000); + const second = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "old" }] }); + expect(second.status).toBe(200); + expect(second.body.sprint_run_id).not.toBe(first.body.sprint_run_id); + expect(h.table.idempotency.size).toBe(1); + }); + + it("別のアカウントへ同じ鍵を送ると、元の実行・ループを返さずに forbidden", async () => { + const acc = await ready(); + const team = (await op("createTeam", "u-1", { items: [{ name: "チーム", seat_type: "standard", idempotency_key: idem() }] })).body.account_id; + await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] }); + await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] }); + expect((await op("startSprintRun", "u-1", { accountId: team, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] })).status).toBe(403); + expect((await op("startLoop", "u-1", { accountId: team, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] })).status).toBe(403); + }); + + it("先行の同じ鍵が途中で作り終えたら、枠・名前の断りより先にその結果を返す", async () => { + const acc = await ready(); + const run = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "race" }] }); + const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] }); + // 2 本目の最初の読み出しだけを、先行の要求が書く前の空として返す + const real = h.table.getIdempotent.bind(h.table); + let blind = 0; + h.table.getIdempotent = async (ref, now) => (blind-- > 0 ? null : real(ref, now)); + blind = 1; + const again = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "race" }] }); + expect(again.status).toBe(200); + expect(again.body.sprint_run_id).toBe(run.body.sprint_run_id); + blind = 1; + const loopAgain = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] }); + expect(loopAgain.status).toBe(200); + expect(loopAgain.body.loop_id).toBe(loop.body.loop_id); + }); + + it("鍵の形が違えば要素だけを 400 にする", async () => { + const acc = await ready(); + expect((await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "空白 あり" }] })).status).toBe(400); + expect(h.table.runs.size).toBe(0); + }); +}); + describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => { + const put = (acc: string, item: unknown) => op("putModelCredential", "u-1", { accountId: acc, items: [item] }); + it("2 種類の保存・差し替え・消去ができ、応答は 4 項目だけで値と ARN を返さない(AC6・AC7)", async () => { const acc = await personal("u-1"); - const saved = await (await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY })).json(); + const saved = (await put(acc, { kind: "anthropic_api_key", api_key: API_KEY })).body; expect(Object.keys(saved).sort()).toEqual(["external_id", "kind", "registered", "updated_at"]); expect(saved).toMatchObject({ registered: true, kind: "anthropic_api_key" }); - const role = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { - kind: "aws_role", - role_arn: "arn:aws:iam::123456789012:role/ndf", - region: "us-east-1", - }); - expect((await role.json()).kind).toBe("aws_role"); + const role = await put(acc, { kind: "aws_role", role_arn: "arn:aws:iam::123456789012:role/ndf", region: "us-east-1" }); + expect(role.body.kind).toBe("aws_role"); expect(secrets.values.size).toBe(1); expect(JSON.parse([...secrets.values.values()][0]!)).toMatchObject({ external_id: `ndf-factory:${acc}` }); - expect((await call("DELETE", `/accounts/${acc}/model-credential`, "u-1")).status).toBe(204); + expect(await op("deleteModelCredential", "u-1", { accountId: acc })).toEqual({ status: 200, body: { removed: true } }); expect(secrets.values.size).toBe(0); - expect(await (await call("GET", `/accounts/${acc}/model-credential`, "u-1")).json()).toEqual({ + expect((await op("getModelCredential", "u-1", { accountId: acc })).body).toEqual({ registered: false, kind: null, updated_at: null, @@ -190,30 +275,33 @@ describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => { }); it("外部 ID はアカウントごとに違う(AC8)", async () => { - const a = await (await call("GET", `/accounts/${await personal("u-1")}/model-credential`, "u-1")).json(); - const b = await (await call("GET", `/accounts/${await personal("u-2")}/model-credential`, "u-2")).json(); + const a = (await op("getModelCredential", "u-1", { accountId: await personal("u-1") })).body; + const b = (await op("getModelCredential", "u-2", { accountId: await personal("u-2") })).body; expect(a.external_id).not.toBe(b.external_id); }); it("形の違う入力は 400、置き場に書けなければ 503", async () => { const acc = await personal("u-1"); - expect((await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400); + expect((await put(acc, { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400); secrets.fail = true; - const res = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY }); + const res = await put(acc, { kind: "anthropic_api_key", api_key: API_KEY }); expect(res.status).toBe(503); - expect((await res.json()).error.code).toBe("credential_unavailable"); + expect(res.body.error.code).toBe("credential_unavailable"); }); }); describe("課題の候補(受け入れ条件 9・10)", () => { + const ask = (acc: string, exclude?: number[]) => + op("postIssueCandidates", "u-1", { accountId: acc, items: [{ repository_id: 101, ...(exclude ? { exclude } : {}) }] }).then((r) => r.body); + it("順位の表があれば理由つきの候補、選び直しで外した番号は出ない(AC9・AC10)", async () => { const acc = await personal("u-1"); await register(acc); h.github.rankings.set("org-a/repo-a", { milestone: "03 Factory", rows: [{ number: 5, title: "e" }, { number: 6, title: "f" }] }); - const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json(); + const res = await ask(acc); expect(res).toMatchObject({ source: "ranking", milestone: "03 Factory", note: null }); expect(res.candidates.every((c: { reason: string }) => c.reason !== "")).toBe(true); - const again = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", { exclude: [5] })).json(); + const again = await ask(acc, [5]); expect(again.candidates.map((c: { number: number }) => c.number)).toEqual([6]); }); @@ -222,7 +310,7 @@ describe("課題の候補(受け入れ条件 9・10)", () => { await register(acc); h.github.openIssues.set("org-a/repo-a", [{ number: 9, title: "i" }, { number: 8, title: "h" }]); model.fn = () => Promise.reject(new Error("ThrottlingException")); - const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json(); + const res = await ask(acc); expect(res.source).toBe("recent"); expect(res.note).toBe(CANDIDATE_FALLBACK_NOTE); expect(res.candidates.map((c: { number: number }) => c.number)).toEqual([9, 8]); @@ -230,24 +318,24 @@ describe("課題の候補(受け入れ条件 9・10)", () => { }); describe("Factory のオーガナイザーの経路(#2439 の AC 9・11、I66)", () => { - it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 409、承認の依頼へ答える経路は無い", async () => { + it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 404、承認の依頼へ答える経路は無い", async () => { const acc = await personal("u-1"); await register(acc); - const list = await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-1"); - expect(list.status).toBe(200); - expect(await list.json()).toEqual({ organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] }); - const history = await call("GET", `/accounts/${acc}/repositories/101/releases?limit=5`, "u-1"); - expect(await history.json()).toEqual({ items: [] }); - expect((await call("GET", `/accounts/${acc}/repositories/101/releases?limit=0`, "u-1")).status).toBe(400); - expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "reject" })).status).toBe(404); - expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "maybe" })).status).toBe(400); - expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/repositories/101/approval-requests`, "u-1", {})).status); + const list = await op("listRepositoryApprovalRequests", "u-1", { accountId: acc, query: { repository_id: [101] } }); + expect(list).toEqual({ status: 200, body: { repository_id: 101, organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] } }); + const history = await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=5`, "u-1"); + expect(await history.json()).toEqual([]); + expect((await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=0`, "u-1")).status).toBe(400); + const resolve = (choice: string) => op("resolveStalledRelease", "u-1", { accountId: acc, items: [{ repository_id: 101, seq: 1, choice }] }); + expect((await resolve("reject")).status).toBe(404); + expect((await resolve("maybe")).status).toBe(400); + expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/approval-requests`, "u-1", [{}])).status); }); it("他人のアカウントのリポジトリは 403", async () => { const acc = await personal("u-1"); await register(acc); await personal("u-2"); - expect((await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-2")).status).toBe(403); + expect((await op("listRepositoryApprovalRequests", "u-2", { accountId: acc, query: { repository_id: [101] } })).status).toBe(403); }); }); diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts index 71b41254f..ccc7cdb08 100644 --- a/factory/api/src/routes/factory.ts +++ b/factory/api/src/routes/factory.ts @@ -1,7 +1,8 @@ // 画面向けの経路(#2186・#2347・#2172)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 -// どの経路も resolveFromRequest でアカウントを解決し、他人のアカウントは #2170 の I18 と同じ 403 -import type { Context, Hono } from "hono"; -import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; +// どの操作も操作の登録の層(operation.ts)で登録し、経路のアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403。 +// 要素の形は機能から起こした契約(#2384)で、要素ごとの失敗は要素の結果になる +import type { Hono } from "hono"; +import type { AuthEnv } from "../auth/resolve.ts"; import type { CredentialService } from "../credentials/service.ts"; import { InvalidRequestError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; @@ -12,7 +13,8 @@ import { chooseCandidates, type CandidateModel } from "../runs/candidates.ts"; import type { RunControl } from "../runs/control.ts"; import type { LoopControl } from "../runs/loops.ts"; import type { RunsTable } from "../runs/table.ts"; -import { readJson } from "./body.ts"; +import { parseIdempotencyKey } from "../idempotency.ts"; +import { positiveInt, registerList, registerOperation } from "./operation.ts"; import { registerSprintViews, sprintRunList } from "./sprint-views.ts"; export interface FactoryDeps { @@ -28,147 +30,115 @@ export interface FactoryDeps { reader: ProgressReader; } -function positiveInt(raw: string | undefined): number { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); - return n; +function idOf(raw: unknown, message: string): string { + if (typeof raw !== "string" || raw === "") throw new InvalidRequestError(message); + return raw; } +const removed = { removed: true } as const; + export function registerFactory(app: Hono, deps: () => FactoryDeps): void { - const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId")); + registerList(app, "getInstallUrl", async () => [deps().repositories.installUrl()]); - app.get("/accounts/:accountId/github/install-url", async (c) => { - await ctxOf(c); - return c.json(deps().repositories.installUrl()); - }); + registerList(app, "getAuthorizeUrl", async (_c, ctx) => [await deps().repositories.authorizeUrl(ctx!)]); - app.get("/accounts/:accountId/github/authorize-url", async (c) => { - return c.json(await deps().repositories.authorizeUrl(await ctxOf(c))); - }); + registerOperation(app, "postRepositoryCandidates", { run: (e, ctx) => deps().repositories.candidates(ctx!, e) }); - app.post("/accounts/:accountId/github/candidates", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.candidates(ctx, await readJson(c))); - }); + registerList(app, "listRepositories", async (_c, ctx) => (await deps().repositories.list(ctx!)).repositories); - app.get("/accounts/:accountId/repositories", async (c) => { - return c.json(await deps().repositories.list(await ctxOf(c))); - }); - - app.post("/accounts/:accountId/repositories", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.register(ctx, await readJson(c)), 201); - }); + registerOperation(app, "registerRepository", { run: (e, ctx) => deps().repositories.register(ctx!, e) }); - app.patch("/accounts/:accountId/repositories/:repositoryId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.declareConcurrency(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c))); + registerOperation(app, "updateRepository", { + run: (e, ctx) => deps().repositories.declareConcurrency(ctx!, positiveInt(e.repository_id), e), }); - app.delete("/accounts/:accountId/repositories/:repositoryId", async (c) => { - const ctx = await ctxOf(c); - await deps().repositories.unregister(ctx, positiveInt(c.req.param("repositoryId"))); - return c.body(null, 204); + registerOperation(app, "removeRepository", { + run: async (e, ctx) => { + await deps().repositories.unregister(ctx!, positiveInt(e.repository_id)); + return removed; + }, }); - app.get("/accounts/:accountId/model-credential", async (c) => { - return c.json(await deps().credentials.get(await ctxOf(c))); - }); + registerList(app, "getModelCredential", async (_c, ctx) => [await deps().credentials.get(ctx!)]); - app.put("/accounts/:accountId/model-credential", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().credentials.put(ctx, await readJson(c))); - }); + registerOperation(app, "putModelCredential", { run: (e, ctx) => deps().credentials.put(ctx!, e) }); - app.delete("/accounts/:accountId/model-credential", async (c) => { - await deps().credentials.delete(await ctxOf(c)); - return c.body(null, 204); + registerOperation(app, "deleteModelCredential", { + run: async (_e, ctx) => { + await deps().credentials.delete(ctx!); + return removed; + }, }); - app.post("/accounts/:accountId/repositories/:repositoryId/issue-candidates", async (c) => { - const ctx = await ctxOf(c); - const body = await readJson(c); - const exclude = Array.isArray(body.exclude) ? body.exclude.filter((n): n is number => Number.isInteger(n)) : []; - const d = deps(); - const list = await chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx, positiveInt(c.req.param("repositoryId")), exclude); - return c.json(list); + registerOperation(app, "postIssueCandidates", { + run: (e, ctx) => { + const exclude = Array.isArray(e.exclude) ? e.exclude.filter((n): n is number => Number.isInteger(n)) : []; + const d = deps(); + return chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx!, positiveInt(e.repository_id), exclude); + }, }); - app.post("/accounts/:accountId/sprint-runs", async (c) => { - const ctx = await ctxOf(c); - const body = await readJson(c); - return c.json(await deps().control.startSprintRun(ctx, { repositoryId: body.repository_id, issues: body.issues }), 201); + // 同じ冪等の鍵の 2 回目は 1 回目の実行の識別子を返す(#2384 の I14) + registerOperation(app, "startSprintRun", { + run: (e, ctx) => + deps().control.startSprintRun(ctx!, { repositoryId: e.repository_id, issues: e.issues, idempotencyKey: parseIdempotencyKey(e.idempotency_key) }), }); - app.get("/accounts/:accountId/sprint-runs", async (c) => { - const ctx = await ctxOf(c); + registerList(app, "listSprintRuns", async (c, ctx) => { const raw = c.req.query("repository_id"); - return c.json({ sprint_runs: await sprintRunList(deps(), ctx, raw === undefined ? null : positiveInt(raw)) }); + return sprintRunList(deps(), ctx!, raw === undefined ? null : positiveInt(raw)); }); // スプリントの詳細・工程の中身・バックログ(#2172) registerSprintViews(app, deps); - app.post("/accounts/:accountId/sprint-runs/:sprintRunId/cancel", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().control.cancelSprintRun(ctx, c.req.param("sprintRunId"))); + registerOperation(app, "cancelSprintRun", { + run: (e, ctx) => deps().control.cancelSprintRun(ctx!, idOf(e.sprint_run_id, "実行の ID の形ではありません")), }); // 開発ループ(openapi/loops.yaml) - app.post("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.startLoop(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c)), 201); - }); + registerOperation(app, "startLoop", { run: (e, ctx) => deps().loops.startLoop(ctx!, positiveInt(e.repository_id), { ...e, idempotency_key: parseIdempotencyKey(e.idempotency_key) }) }); - app.get("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.listLoops(ctx, positiveInt(c.req.param("repositoryId")))); - }); + registerList(app, "listLoops", async (c, ctx) => (await deps().loops.listLoops(ctx!, positiveInt(c.req.query("repository_id")))).loops); - app.get("/accounts/:accountId/loops/:loopId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.getLoopDetail(ctx, c.req.param("loopId"))); - }); + // 無いループは今の 404(loop_not_found)のまま要素の結果になる + registerOperation(app, "getLoop", { run: (e, ctx) => deps().loops.getLoopDetail(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.patch("/accounts/:accountId/loops/:loopId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.updateBudget(ctx, c.req.param("loopId"), await readJson(c))); + registerOperation(app, "updateLoopBudget", { + run: (e, ctx) => deps().loops.updateBudget(ctx!, idOf(e.loop_id, "ループの ID の形ではありません"), { budget: e.budget }), }); - app.post("/accounts/:accountId/loops/:loopId/cancel", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.cancelLoop(ctx, c.req.param("loopId"))); - }); + registerOperation(app, "cancelLoop", { run: (e, ctx) => deps().loops.cancelLoop(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.post("/accounts/:accountId/loops/:loopId/next-lap", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.requestNextLap(ctx, c.req.param("loopId")), 202); - }); + registerOperation(app, "startNextLap", { run: (e, ctx) => deps().loops.requestNextLap(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.post("/accounts/:accountId/loops/:loopId/answers", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.answer(ctx, c.req.param("loopId"), await readJson(c))); + // 人の判断待ちへの答え。人だけが確定できる(扱い human) + registerOperation(app, "answerLoopPending", { + run: async (e, ctx) => { + const loopId = idOf(e.loop_id, "ループの ID の形ではありません"); + const { pending } = await deps().loops.answer(ctx!, loopId, { issue: e.issue, decision: e.decision, pr: e.pr }); + return { loop_id: loopId, pending }; + }, }); - // Factory のオーガナイザー(openapi/organizer.yaml。#2439)。一覧と履歴は読むだけで、承認を確定する経路は持たない(I66) - app.get("/accounts/:accountId/repositories/:repositoryId/approval-requests", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().organizer.list(ctx, positiveInt(c.req.param("repositoryId")))); + // Factory のオーガナイザー(openapi/organizer.yaml。#2439) + registerOperation(app, "listRepositoryApprovalRequests", { + run: async (e, ctx) => { + const repositoryId = positiveInt(e.repository_id); + return { repository_id: repositoryId, ...(await deps().organizer.list(ctx!, repositoryId)) }; + }, }); - app.get("/accounts/:accountId/repositories/:repositoryId/releases", async (c) => { - const ctx = await ctxOf(c); + registerList(app, "listRepositoryReleases", async (c, ctx) => { const raw = c.req.query("limit"); const limit = raw === undefined ? 20 : Number(raw); if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new InvalidRequestError("limit は 1〜100 の整数です"); - return c.json(await deps().organizer.history(ctx, positiveInt(c.req.param("repositoryId")), limit)); + return (await deps().organizer.history(ctx!, positiveInt(c.req.query("repository_id")), limit)).items; }); - // 止まった配布を解く。画面の経路は Cognito の利用者の token だけを受ける(承認ゲートではなく失敗の扱い。決定 11) - app.post("/accounts/:accountId/repositories/:repositoryId/releases/:seq/resolve", async (c) => { - const ctx = await ctxOf(c); - const seq = Number(c.req.param("seq")); - if (!Number.isInteger(seq) || seq < 1) throw new InvalidRequestError("まとめた配布の連番の形ではありません"); - return c.json(await deps().organizer.resolve(ctx, positiveInt(c.req.param("repositoryId")), seq, await readJson(c)), 202); + // 止まった配布を解く。人だけが確定できる(扱い human。オーガナイザーの I66) + registerOperation(app, "resolveStalledRelease", { + run: (e, ctx) => + deps().organizer.resolve(ctx!, positiveInt(e.repository_id), positiveInt(e.seq, "まとめた配布の連番の形ではありません"), { choice: e.choice }), }); } diff --git a/factory/api/src/routes/me.ts b/factory/api/src/routes/me.ts index 4cd5a0a48..5732e8ee3 100644 --- a/factory/api/src/routes/me.ts +++ b/factory/api/src/routes/me.ts @@ -1,16 +1,19 @@ import type { Hono } from "hono"; import type { AuthEnv } from "../auth/resolve.ts"; +import { registerList } from "./operation.ts"; import { accountSummary } from "./views.ts"; export function registerMe(app: Hono): void { - // 初回は個人のアカウントを作る(E2) - app.get("/me", async (c) => { + // 初回は個人のアカウントを作る(E2)。要素 1 個の配列で返す + registerList(app, "getMe", async (c) => { const { user, contexts } = await c.get("auth").accounts.listAccounts(c.get("caller")); - return c.json({ - user_id: user.userId, - idp: user.idp, - personal_account_id: user.personalAccountId, - accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)), - }); + return [ + { + user_id: user.userId, + idp: user.idp, + personal_account_id: user.personalAccountId, + accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)), + }, + ]; }); } diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts new file mode 100644 index 000000000..837d78f28 --- /dev/null +++ b/factory/api/src/routes/operation.ts @@ -0,0 +1,141 @@ +// 操作の登録の層(#2384 の決定 5・不変条件 I10・I12・I13)。経路を操作の表から登録し、要素の数と重複の検査・ +// 経路のアカウントの解決・要素ごとの実行・操作の記録へ渡す値を、すべての利用者向けの操作に同じく掛ける +import type { Context, Hono } from "hono"; +import { MAX_ITEMS, operation, type Operation, type OperationId } from "@ndf-factory/contracts/operations"; +import type { AccountContext } from "../accounts/model.ts"; +import { resolveFromRequest, type AuthEnv, type OperationTrace } from "../auth/resolve.ts"; +import { HumanOnlyError, InvalidRequestError, ScopeError } from "../errors.ts"; +import { runElements, type ElementResult } from "./elements.ts"; + +export type Element = Record; + +export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number { + const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw; + if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message); + return n as number; +} + +export interface ElementHandler { + // 要求全体の前に 1 度だけ行う(条件の照合など)。投げると要求全体を断る + prepare?: (c: Context, ctx: AccountContext | null) => Promise; + // 要素 1 つの値を返す。ApiError を投げるとその要素だけが失敗の結果になる + run: (element: Element, ctx: AccountContext | null, shared: S, c: Context) => Promise; +} + +const honoPath = (op: Operation) => op.path.replace("{accountId}", ":accountId"); +const method = (op: Operation) => op.method.toLowerCase() as "get" | "post" | "put" | "patch" | "delete"; + +// 経路にアカウントがあれば今と同じく解決し、権限が無ければ要求全体を 403 にする(決定 2) +async function contextOf(c: Context, op: Operation): Promise { + if (!op.path.includes("{accountId}")) return null; + return resolveFromRequest(c, c.req.param("accountId")); +} + +// 委任の資格情報の範囲と、操作の扱いを照らす(E3・E5、I4・I5)。人だけが確定できる操作は範囲によらず断る +function guard(c: Context, op: Operation): void { + const delegation = c.get("caller").delegation; + if (!delegation) return; + if (op.access === "human") throw new HumanOnlyError(); + if (op.access === "write" && delegation.scope !== "read_write") throw new ScopeError(); +} + +function trace(c: Context, op: Operation): OperationTrace { + const t: OperationTrace = { operationId: op.operationId, accountId: op.path.includes("{accountId}") ? (c.req.param("accountId") ?? null) : null, targetIds: [], reasons: [] }; + c.set("operation", t); + guard(c, op); + return t; +} + +function queryValue(op: Operation, key: string, raw: string): unknown { + const schema = op.parameters.find((p) => p.name === key)?.schema as { items?: { type?: string } } | undefined; + return schema?.items?.type === "integer" && /^\d+$/.test(raw) ? Number(raw) : raw; +} + +// 要素を取り出す。鍵の query の繰り返しか、本文の配列。鍵を持たない操作の取得と削除は要素が必ず 1 個 +async function elementsOf(c: Context, op: Operation): Promise { + if (op.kind === "select" || op.kind === "delete") { + if (op.keys.length === 0) return [{}]; + const key = op.keys[0]!; + return (c.req.queries(key) ?? []).map((raw) => ({ [key]: queryValue(op, key, raw) })); + } + let body: unknown; + try { + body = await c.req.json(); + } catch { + throw new InvalidRequestError("本文は要素の配列にしてください"); + } + if (!Array.isArray(body)) throw new InvalidRequestError("本文は要素の配列にしてください"); + return body; +} + +const isElement = (v: unknown): v is Element => typeof v === "object" && v !== null && !Array.isArray(v); + +// 要素の数と重複の検査(I12)。どれかに当たれば、どの要素も処理しない +function checkElements(op: Operation, elements: unknown[]): void { + if (elements.length === 0) throw new InvalidRequestError("要素を 1 個以上送ってください", "empty_items"); + if (elements.length > MAX_ITEMS) { + throw new InvalidRequestError(`1 回に送れる要素は ${MAX_ITEMS} 個までです`, "too_many_items", { limit: MAX_ITEMS }); + } + const seen = new Set(); + for (const e of elements) { + const id = op.keys.length === 0 ? "" : JSON.stringify(op.keys.map((k) => (isElement(e) ? (e[k] ?? null) : null))); + if (seen.has(id)) { + throw new InvalidRequestError("同じ鍵の要素が 2 つあります", "duplicate_key", { key: op.keys.length === 0 ? null : op.keys.join("+") }); + } + seen.add(id); + } +} + +function pick(value: unknown, path: string): unknown { + return path.split(".").reduce((v, k) => (isElement(v) ? v[k] : undefined), value); +} + +// 操作の記録へ渡す対象の ID(要素の鍵と作った識別子)と、失敗した要素の理由 +function record(t: OperationTrace, op: Operation, elements: unknown[], results: ElementResult[]): void { + elements.forEach((e, i) => { + for (const k of op.keys) { + const v = isElement(e) ? e[k] : undefined; + if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${k}=${v}`); + } + const r = results[i]; + if (op.idField && r?.ok) { + const v = pick(r.value, op.idField); + if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${op.idField.split(".").at(-1)}=${v}`); + } + if (r && !r.ok && !t.reasons.includes(r.error.code)) t.reasons.push(r.error.code); + }); +} + +// 一覧(条件で集めた値の配列。要素の結果で包まない) +export function registerList( + app: Hono, + operationId: OperationId, + list: (c: Context, ctx: AccountContext | null) => Promise, +): void { + const op = operation(operationId); + if (op.kind !== "list") throw new Error(`${operationId} は一覧ではありません`); + app[method(op)](honoPath(op), async (c) => { + trace(c, op); + const ctx = await contextOf(c, op); + return c.json(await list(c, ctx)); + }); +} + +// 一覧を除く操作(指定の取得・作成・更新・削除・操作) +export function registerOperation(app: Hono, operationId: OperationId, handler: ElementHandler): void { + const op = operation(operationId); + if (op.kind === "list") throw new Error(`${operationId} は一覧です`); + app[method(op)](honoPath(op), async (c) => { + const t = trace(c, op); + const elements = await elementsOf(c, op); + checkElements(op, elements); + const ctx = await contextOf(c, op); + const shared = (handler.prepare ? await handler.prepare(c, ctx) : undefined) as S; + const results = await runElements(elements, (e) => { + if (!isElement(e)) throw new InvalidRequestError("要素はオブジェクトにしてください"); + return handler.run(e, ctx, shared, c); + }); + record(t, op, elements, results); + return c.json(results); + }); +} diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts index 241ae60a9..c463e8cbb 100644 --- a/factory/api/src/routes/sprint-views.test.ts +++ b/factory/api/src/routes/sprint-views.test.ts @@ -12,7 +12,7 @@ import { LoopControl } from "../runs/loops.ts"; import type { SprintRun } from "../runs/model.ts"; import { createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createTokenKit, type TokenKit } from "../testing/tokens.ts"; -import type { TestResponse } from "../testing/app.ts"; +import { firstResult, type TestResponse } from "../testing/app.ts"; import type { FactoryDeps } from "./factory.ts"; const noSecrets: SecretsPort = { create: async () => "arn", put: async () => undefined, delete: async () => undefined }; @@ -27,8 +27,14 @@ async function call(path: string, user = "u-1"): Promise { return (await app.request(path, { headers: { Authorization: await tokens.bearer(user) } })) as TestResponse; } +// 要素 1 個の結果を状態と本文で読む(#2384) +const read = async (path: string, user = "u-1") => firstResult(await call(path, user)); +const detailPath = (acc: string, id: string) => `/accounts/${acc}/sprint-runs/detail?sprint_run_id=${encodeURIComponent(id)}`; +const stepPath = (acc: string, id: string, key: string) => `/accounts/${acc}/sprint-runs/steps?sprint_run_id=${encodeURIComponent(id)}&step_key=${encodeURIComponent(key)}`; +const backlogPath = (acc: string, repositoryId: number) => `/accounts/${acc}/backlogs?repository_id=${repositoryId}`; + async function personal(user: string): Promise { - return ((await (await call("/me", user)).json()) as { personal_account_id: string }).personal_account_id; + return ((await (await call("/me", user)).json())[0] as { personal_account_id: string }).personal_account_id; } function runOf(accountId: string, patch: Partial = {}): SprintRun { @@ -128,7 +134,7 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => { const { acc } = await seed(); const res = await call(`/accounts/${acc}/sprint-runs`); expect(res.status).toBe(200); - const { sprint_runs } = await res.json(); + const sprint_runs = await res.json(); expect(sprint_runs.map((r: { sprint_run_id: string }) => r.sprint_run_id)).toEqual(["sr-2", "sr-1"]); const row = sprint_runs[1]; expect(row).toEqual({ @@ -150,15 +156,15 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => { describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)", () => { it("詳細は本文を返さず、工程の中身は 1 件ずつ本文を返す。どちらも GitHub の口を呼ばない", async () => { const { acc, step } = await seed(); - const detail = await call(`/accounts/${acc}/sprint-runs/sr-1`); + const detail = await read(detailPath(acc, "sr-1")); expect(detail.status).toBe(200); - const body = await detail.json(); + const body = detail.body; expect(body).toMatchObject({ sprint_run_id: "sr-1", repo: "org-a/repo-a", current_phase: "実装", ndf_version: "10.27.15", job_count: 1, omitted_steps: 0 }); expect(body.steps[0]).toMatchObject({ key: step, phase: "実装", issues: [11], has_details: true }); expect(JSON.stringify(body)).not.toContain("## 作業の報告"); - const one = await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); + const one = await read(stepPath(acc, "sr-1", step)); expect(one.status).toBe(200); - expect((await one.json()).output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + expect(one.body.output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); await call(`/accounts/${acc}/sprint-runs`); expect(githubCalls).toEqual([]); }); @@ -166,9 +172,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)" it("足す前の形の step.recorded(schema_version 1 のまま)も詳細に出て、本文の欄は null", async () => { const { acc } = await seed(); const key = await emit("sr-1", "step.recorded", { step: "pr", exit_code: 0, seconds: 1, summary: "作った" }); - const body = await (await call(`/accounts/${acc}/sprint-runs/sr-1`)).json(); + const body = (await read(detailPath(acc, "sr-1"))).body; expect(body.steps.map((s: { key: string }) => s.key)).toContain(key); - const one = await (await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(key)}`)).json(); + const one = (await read(stepPath(acc, "sr-1", key))).body; expect([one.files, one.decisions, one.output]).toEqual([null, null, null]); }); @@ -176,9 +182,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)" const { acc, step } = await seed(); const before = JSON.stringify([h.progress.events, [...h.table.runs.values()]]); await call(`/accounts/${acc}/sprint-runs`); - await call(`/accounts/${acc}/sprint-runs/sr-1`); - await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); - await call(`/accounts/${acc}/repositories/101/backlog`); + await call(detailPath(acc, "sr-1")); + await call(stepPath(acc, "sr-1", step)); + await call(backlogPath(acc, 101)); expect(JSON.stringify([h.progress.events, [...h.table.runs.values()]])).toBe(before); }); }); @@ -188,28 +194,24 @@ describe("認可(受け入れ条件 11・I12)", () => { const { acc, step } = await seed(); const other = await personal("u-2"); h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other" })); - const paths = [ - `/accounts/${acc}/sprint-runs`, - `/accounts/${acc}/sprint-runs/sr-1`, - `/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`, - `/accounts/${acc}/repositories/101/backlog`, - ]; + const paths = [`/accounts/${acc}/sprint-runs`, detailPath(acc, "sr-1"), stepPath(acc, "sr-1", step), backlogPath(acc, 101)]; const missing = "00000000-0000-4000-8000-0000000000ff"; const cases = [ ...paths.map((p) => [p, "u-2"] as const), ...paths.map((p) => [p.replace(acc, missing), "u-1"] as const), - [`/accounts/${acc}/sprint-runs/sr-other`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-none`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-other/steps/${encodeURIComponent(step)}`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-1/steps/job-1%3A999`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-2/steps/${encodeURIComponent(step)}`, "u-1"], - [`/accounts/${acc}/repositories/999/backlog`, "u-1"], + [detailPath(acc, "sr-other"), "u-1"], + [detailPath(acc, "sr-none"), "u-1"], + [stepPath(acc, "sr-other", step), "u-1"], + [stepPath(acc, "sr-1", "job-1:999"), "u-1"], + [stepPath(acc, "sr-2", step), "u-1"], + [backlogPath(acc, 999), "u-1"], ] as const; + // 要求全体の 403 と要素の forbidden は、同じ本文を持つ const bodies = new Set(); for (const [path, user] of cases) { - const res = await call(path, user); + const res = await read(path, user); expect(res.status, path).toBe(403); - bodies.add(await res.text()); + bodies.add(JSON.stringify(res.body)); } expect([...bodies]).toEqual([JSON.stringify({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } })]); }); @@ -221,9 +223,9 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () => h.github.rankings.set("org-a/repo-a", { milestone: "25 見本", rows: [{ number: 5, title: "先に" }, { number: 3, title: "後に" }] }); await emit("sr-1", "step.recorded", { step: "refine", exit_code: 0, seconds: 3, summary: "古い棚卸し" }, "2026-11-02T00:00:00.000Z"); await emit("sr-2", "step.recorded", { step: "refine", exit_code: 1, seconds: 3, summary: "新しい棚卸し" }, "2026-11-03T00:00:00.000Z"); - const res = await call(`/accounts/${acc}/repositories/101/backlog`); + const res = await read(backlogPath(acc, 101)); expect(res.status).toBe(200); - const body = await res.json(); + const body = res.body; expect(body.repo).toBe("org-a/repo-a"); expect(body.ranking).toEqual({ milestone: "25 見本", @@ -238,12 +240,54 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () => it("順位の表が無ければ ranking は null、GitHub の口が失敗すれば 503", async () => { const { acc } = await seed(); - const none = await (await call(`/accounts/${acc}/repositories/101/backlog`)).json(); + const none = (await read(backlogPath(acc, 101))).body; expect(none.ranking).toBeNull(); expect(none.refinements).toEqual([]); h.github.findRankingTable = async () => { throw new Error("GitHub 502"); }; - expect((await call(`/accounts/${acc}/repositories/101/backlog`)).status).toBe(503); + expect((await read(backlogPath(acc, 101))).status).toBe(503); + }); +}); + +describe("配列の受け渡し(#2384 の受け入れ条件 6〜8・15、I12・I13)", () => { + it("3 個の要素のうち他人の 1 個だけが forbidden で、順と数は入力と同じ。他人のデータは応答に無い", async () => { + const { acc } = await seed(); + const other = await personal("u-2"); + h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other", repo: "org-x/secret-repo" })); + const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-other&sprint_run_id=sr-2`); + expect(res.status).toBe(200); + const results = await res.json(); + expect(results.map((r: { ok: boolean; value?: { sprint_run_id: string }; error?: { code: string } }) => (r.ok ? r.value!.sprint_run_id : r.error!.code))).toEqual([ + "sr-1", + "forbidden", + "sr-2", + ]); + expect(JSON.stringify(results)).not.toContain("secret-repo"); + }); + + it("101 個の要素は、どれも処理せずに 400 と上限 100、0 個は 400", async () => { + const { acc } = await seed(); + const reads: string[] = []; + const getRun = h.table.getRun.bind(h.table); + h.table.getRun = async (id: string) => { + reads.push(id); + return getRun(id); + }; + const many = Array.from({ length: 101 }, (_, i) => `sprint_run_id=sr-${i}`).join("&"); + const res = await call(`/accounts/${acc}/sprint-runs/detail?${many}`); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatchObject({ code: "too_many_items", details: { limit: 100 } }); + const empty = await call(`/accounts/${acc}/sprint-runs/detail`); + expect(empty.status).toBe(400); + expect((await empty.json()).error.code).toBe("empty_items"); + expect(reads).toEqual([]); + }); + + it("同じ鍵の要素が 2 つあれば 400 duplicate_key", async () => { + const { acc } = await seed(); + const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-1`); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatchObject({ code: "duplicate_key", details: { key: "sprint_run_id" } }); }); }); diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts index 95407222d..941a1103a 100644 --- a/factory/api/src/routes/sprint-views.ts +++ b/factory/api/src/routes/sprint-views.ts @@ -1,39 +1,22 @@ // スプリントの一覧・詳細・工程の中身・バックログの読み出しの経路(#2172)。読むだけで、事象の表・実行の表・GitHub へ書かない(I14)。 -// アカウントは resolveFromRequest で解決し、他人のアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは -// どれも同じ本文の 403(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) -import type { Context, Hono } from "hono"; +// アカウントは操作の登録の層が解決する。属さない実行・無い実行・無い工程・登録の無いリポジトリの要素は、 +// どれも他人のアカウントと同じ本文の forbidden の結果(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) +import type { Hono } from "hono"; import type { AccountContext } from "../accounts/model.ts"; -import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; +import type { AuthEnv } from "../auth/resolve.ts"; import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts"; import type { RankingTable } from "../github/port.ts"; import type { SprintRun } from "../runs/model.ts"; import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts"; +import { mapLimit } from "./elements.ts"; import type { FactoryDeps } from "./factory.ts"; +import { positiveInt, registerOperation } from "./operation.ts"; // 一覧の行ごとの読み出しを並べる数の上限 const LIST_CONCURRENCY = 10; // バックログで棚卸しの記録を読む実行の数(新しい順) const BACKLOG_RUNS = 50; -export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const out = new Array(items.length); - let next = 0; - const worker = async () => { - while (next < items.length) { - const i = next++; - out[i] = await fn(items[i]!); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); - return out; -} - -function positiveInt(raw: string | undefined): number { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); - return n; -} - // 保存の失敗は 503(応答に中身を出さない) async function readOrUnavailable(read: () => Promise): Promise { try { @@ -69,49 +52,57 @@ export async function sprintRunList(deps: FactoryDeps, ctx: AccountContext, repo } export function registerSprintViews(app: Hono, deps: () => FactoryDeps): void { - const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId")); - - app.get("/accounts/:accountId/sprint-runs/:sprintRunId", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const run = await ownRun(d, ctx, c.req.param("sprintRunId")); - const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); - return c.json(deriveDetail(run, events, omittedSteps)); + registerOperation(app, "getSprintRunDetail", { + run: async (e, ctx) => { + const d = deps(); + const run = await ownRun(d, ctx!, typeof e.sprint_run_id === "string" ? e.sprint_run_id : ""); + const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); + return deriveDetail(run, events, omittedSteps); + }, }); - app.get("/accounts/:accountId/sprint-runs/:sprintRunId/steps/:stepKey", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const run = await ownRun(d, ctx, c.req.param("sprintRunId")); - const key = c.req.param("stepKey"); - const [record, { events }] = await readOrUnavailable(() => Promise.all([d.reader.getStep(run.sprintRunId, key), d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)])); - if (!record) throw new ForbiddenError(); - // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない) - const row = stepRows(events).find((r) => r.key === key) ?? stepRows([record])[0]!; - return c.json(deriveStepDetail(run, row, record)); + // 1 つの実行の中の工程の鍵を要素にする。実行は条件で、他人の実行なら要求全体が 403 + registerOperation(app, "getStepDetail", { + prepare: async (c, ctx) => { + const ids = c.req.queries("sprint_run_id") ?? []; + if (ids.length !== 1) throw new InvalidRequestError("sprint_run_id を 1 つ指してください"); + const d = deps(); + const run = await ownRun(d, ctx!, ids[0]!); + const { events } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); + return { run, rows: stepRows(events) }; + }, + run: async (e, _ctx, { run, rows }) => { + const key = typeof e.step_key === "string" ? e.step_key : ""; + const record = await readOrUnavailable(() => deps().reader.getStep(run.sprintRunId, key)); + if (!record) throw new ForbiddenError(); + // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない) + const row = rows.find((r) => r.key === key) ?? stepRows([record])[0]!; + return deriveStepDetail(run, row, record); + }, }); - app.get("/accounts/:accountId/repositories/:repositoryId/backlog", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const repositoryId = positiveInt(c.req.param("repositoryId")); - const registration = await d.table.getRegistration(ctx.accountId, repositoryId); - if (!registration) throw new ForbiddenError(); - const runs = (await d.table.listRuns(ctx.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS); - const [ranking, perRun] = await Promise.all([ - d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => { - throw new ServiceUnavailableError("github_unavailable"); - }), - readOrUnavailable(() => - mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })), - ), - ]); - return c.json({ - repository_id: repositoryId, - repo: registration.fullName, - ranking: rankingOf(ranking), - refinements: deriveRefinements(perRun), - }); + registerOperation(app, "getBacklog", { + run: async (e, ctx) => { + const d = deps(); + const repositoryId = positiveInt(e.repository_id); + const registration = await d.table.getRegistration(ctx!.accountId, repositoryId); + if (!registration) throw new ForbiddenError(); + const runs = (await d.table.listRuns(ctx!.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS); + const [ranking, perRun] = await Promise.all([ + d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => { + throw new ServiceUnavailableError("github_unavailable"); + }), + readOrUnavailable(() => + mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })), + ), + ]); + return { + repository_id: repositoryId, + repo: registration.fullName, + ranking: rankingOf(ranking), + refinements: deriveRefinements(perRun), + }; + }, }); } diff --git a/factory/api/src/routes/teams.ts b/factory/api/src/routes/teams.ts index 49910e565..77c0d275c 100644 --- a/factory/api/src/routes/teams.ts +++ b/factory/api/src/routes/teams.ts @@ -3,17 +3,22 @@ import { InvalidRequestError } from "../errors.ts"; import { isSeatType } from "../accounts/model.ts"; import { validateTeamName } from "../accounts/service.ts"; import type { AuthEnv } from "../auth/resolve.ts"; -import { readJson } from "./body.ts"; +import { parseIdempotencyKey } from "../idempotency.ts"; +import { registerOperation } from "./operation.ts"; import { accountDetail } from "./views.ts"; export function registerTeams(app: Hono): void { - app.post("/teams", async (c) => { - const body = await readJson(c); - const name = validateTeamName(body.name); - if (!isSeatType(body.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です"); - const accounts = c.get("auth").accounts; - const ctx = await accounts.createTeam(c.get("caller"), name, body.seat_type); - const seats = await accounts.listSeats(ctx); - return c.json(accountDetail(ctx, { name }, seats), 201); + // 同じ冪等の鍵の 2 回目は 1 回目のチームを返す(#2384 の I14) + registerOperation(app, "createTeam", { + run: async (e, _ctx, _s, c) => { + const name = validateTeamName(e.name); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です"); + const key = parseIdempotencyKey(e.idempotency_key); + const accounts = c.get("auth").accounts; + const ctx = await accounts.createTeam(c.get("caller"), name, e.seat_type, key); + const account = await accounts.getAccount(ctx.accountId); + const seats = await accounts.listSeats(ctx); + return accountDetail(ctx, { name: account?.name ?? name }, seats); + }, }); } diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts index edef472e5..5efedf06b 100644 --- a/factory/api/src/runs/control.ts +++ b/factory/api/src/runs/control.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import type { JobFinishedEvent, RunJobInstruction } from "@ndf-factory/contracts"; import { judgeLimits } from "../accounts/limits.ts"; import type { AccountContext } from "../accounts/model.ts"; -import { ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; +import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; import type { OrganizerWrite, OrgRequest } from "../organizer/model.ts"; import { finishedKey } from "../progress/authorize.ts"; @@ -31,6 +31,7 @@ import { import type { RunnerPlatform } from "./platform.ts"; import type { RunsTable } from "./table.ts"; import { isLegacyVersion } from "./version.ts"; +import { claimOf, type IdempotencyRef } from "../idempotency.ts"; export const DEFAULT_TIMEOUT_SECONDS = 28_800; export const DEFAULT_TOKEN_TTL_SECONDS = 32_400; @@ -124,6 +125,8 @@ export class ResumeRejectedError extends Error { export interface StartInput { repositoryId: unknown; issues: unknown; + // 作成の冪等の鍵(経路が形を確かめて渡す。#2384 の I14)。無ければ鍵の行を書かない + idempotencyKey?: string; } // 手元のマシンの申告(#2189 の登録と心拍。契約の local-runner-declaration) @@ -181,7 +184,6 @@ export class RunControl { // 開始。確かめの順は 課題の形(400)→ 登録(404)→ 課題(400)→ ループのマイルストーン(409)→ キー(409)→ 導入(409)→ // 使える量(409)→ 版(409)→ 課題の持ち分(409) async startSprintRun(ctx: AccountContext, input: StartInput): Promise<{ sprint_run_id: string; state: "starting" }> { - const { table, github } = this.deps; const repositoryId = input.repositoryId; if (!Number.isInteger(repositoryId) || (repositoryId as number) < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); const issues = input.issues; @@ -194,21 +196,54 @@ export class RunControl { ) { throw new InvalidRequestError(`課題は 1〜${MAX_ISSUES} 件の番号で選んでください`, "invalid_issues", { numbers: [] }); } - const registration = await table.getRegistration(ctx.accountId, repositoryId as number); + const idempotency: IdempotencyRef | undefined = + input.idempotencyKey === undefined ? undefined : { userId: ctx.userId, operationId: "startSprintRun", key: input.idempotencyKey }; + // 同じ鍵の 2 回目は 1 回目の結果を返し、何も確かめず何も書かない + const replayed = idempotency ? await this.replayRun(ctx, idempotency) : null; + if (replayed) return replayed; + try { + return await this.createSprintRun(ctx, repositoryId as number, issues as number[], idempotency); + } catch (err) { + // 同じ鍵の先行の要求が途中で作り終えていれば、枠・持ち分の断りより先にその実行を返す + const won = idempotency && err instanceof ApiError ? await this.replayRun(ctx, idempotency) : null; + if (won) return won; + throw err; + } + } + + // 期限の中の同じ鍵が作った実行。別のアカウントの実行なら返さずに forbidden(鍵は利用者ごとで、アカウントをまたぐ) + private async replayRun(ctx: AccountContext, ref: IdempotencyRef): Promise<{ sprint_run_id: string; state: "starting" } | null> { + const runId = await this.deps.table.getIdempotent(ref, this.iso()); + if (!runId) return null; + const run = await this.deps.table.getRun(runId); + if (!run || run.accountId !== ctx.accountId) throw new ForbiddenError(); + return { sprint_run_id: runId, state: "starting" }; + } + + private async createSprintRun( + ctx: AccountContext, + repositoryId: number, + issues: number[], + idempotency: IdempotencyRef | undefined, + ): Promise<{ sprint_run_id: string; state: "starting" }> { + const { table, github } = this.deps; + const registration = await table.getRegistration(ctx.accountId, repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); - const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues as number[]); + const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues); if (check.invalid.length > 0) throw new InvalidRequestError("開いている課題ではありません", "invalid_issues", { numbers: check.invalid }); // 動いているループのマイルストーンに属する課題は断る(ループの周回の選定と取り合う。決定 11) const titles = [...new Set(Object.values(check.milestones ?? {}).filter((t): t is string => typeof t === "string" && t !== ""))]; await this.refuseIssuesInLoop(ctx.accountId, registration.repositoryId, titles, check.milestones ?? {}); const created = await this.admit(ctx, registration, { - issues: issues as number[], + issues: issues, milestoneTitles: titles, milestonesOf: check.milestones ?? {}, start: "new", + ...(idempotency ? { idempotency } : {}), }); - if (created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable"); + // duplicate は同じ鍵の作成が同時に走り、先に書いた方が勝った。勝った実行は呼び出し元が鍵から引き直す + if (created === "duplicate" || created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable"); return { sprint_run_id: created.sprintRunId, state: "starting" }; } @@ -246,8 +281,9 @@ export class RunControl { milestonesOf: Record; start: "new" | "loop"; loop?: { loop: DevLoop; write: (run: SprintRun) => LoopWrite }; + idempotency?: IdempotencyRef; }, - ): Promise { + ): Promise { const { table, github } = this.deps; let modelCredentialSecretArn: string | null = null; if (ctx.executionMode === "byok") { @@ -315,13 +351,14 @@ export class RunControl { milestoneChecks: opts.milestoneTitles, legacy, ...(opts.loop ? { loop: opts.loop.write(run) } : {}), + ...(opts.idempotency ? { idempotency: claimOf(opts.idempotency, run.sprintRunId, now) } : {}), }; const result = await table.createRun(run, job, slots, guards); if (result === "created") { await this.launch({ ...run, legacy }, job); return { ...run, legacy }; } - if (result === "loop_changed") return "loop_changed"; + if (result === "loop_changed" || result === "duplicate") return result; // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) if (result === "release_running") { throw new ConflictError("parallel_unsupported_version", "このリポジトリでは、並行を許さない NDF の版の実行と並べて動かせません"); @@ -357,7 +394,9 @@ export class RunControl { const registration = await this.deps.table.getRegistration(loop.accountId, loop.repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const ctx: AccountContext = { accountId: loop.accountId, ...loop.context }; - return this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } }); + const created = await this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } }); + // 周回の開始は冪等の鍵の行を書かないため duplicate にならない + return created === "duplicate" ? "loop_changed" : created; } // その UTC の暦月に、アカウントの managed の実行が使った token(形態 managed の上限の判定に使う)。 diff --git a/factory/api/src/runs/dynamo-table.ts b/factory/api/src/runs/dynamo-table.ts index 8a96af074..3ecf26241 100644 --- a/factory/api/src/runs/dynamo-table.ts +++ b/factory/api/src/runs/dynamo-table.ts @@ -28,6 +28,7 @@ import type { } from "./model.ts"; import { isTerminalJob, milestoneHash } from "./model.ts"; import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; export const GSI1 = "gsi1"; const ACTIVE_JOBS = "ACTIVE_JOBS"; @@ -256,6 +257,9 @@ export class DynamoRunsTable implements RunsTable { items.push({ Put: { TableName: this.tableName, Item: { pk: accountPk(run.accountId), sk: loopRunSk(run.loopId, run.sprintRunId), sprintRunId: run.sprintRunId } } }); } // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62。オーガナイザーの根を読むだけ) + // 作成の冪等の鍵の行(#2384 の I14)。期限を過ぎた行は上書きする + const idempotencyAt = items.length; + if (guards?.idempotency) items.push(this.idempotencyPut(guards.idempotency)); const organizerAt = items.length; if (guards?.legacy) { items.push({ @@ -273,6 +277,7 @@ export class DynamoRunsTable implements RunsTable { } catch (err) { const failed = failedIndexes(err); if (failed.length === 0 && !isConditionFailure(err)) throw err; + if (guards?.idempotency && failed.includes(idempotencyAt)) return "duplicate"; if (guards?.legacy && failed.includes(organizerAt)) return "release_running"; if (failed.some((i) => i >= loopStart)) return "loop_changed"; if (failed.some((i) => i >= milestoneStart)) return "issue_in_loop"; @@ -732,8 +737,24 @@ export class DynamoRunsTable implements RunsTable { ); } - async createLoop(loop: DevLoop): Promise<"created" | { conflict: string }> { - const items = [ + private idempotencyPut(claim: IdempotencyClaim): Record { + return { + Put: { + TableName: this.tableName, + Item: { pk: idempotencyPk(claim), sk: "IDEMP", createdId: claim.createdId, createdAt: claim.createdAt, ttl: claim.ttl }, + ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(claim.createdAt) }, + }, + }; + } + + async getIdempotent(ref: IdempotencyRef, now: string): Promise { + return liveCreatedId(await this.get<{ createdId: string; ttl: number }>(idempotencyPk(ref), "IDEMP"), now); + } + + async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }> { + const items: Record[] = [ ...loop.milestones.map((title) => ({ Put: { TableName: this.tableName, @@ -754,11 +775,15 @@ export class DynamoRunsTable implements RunsTable { }, }, ]; + const idempotencyAt = items.length; + if (idempotency) items.push(this.idempotencyPut(idempotency)); try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); return "created"; } catch (err) { - const failed = failedIndexes(err).filter((i) => i < loop.milestones.length); + const all = failedIndexes(err); + if (idempotency && all.includes(idempotencyAt)) return "duplicate"; + const failed = all.filter((i) => i < loop.milestones.length); if (failed.length === 0 && !isConditionFailure(err)) throw err; return { conflict: loop.milestones[failed[0] ?? 0]! }; } diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts index 9c5711f5a..fa1cc337d 100644 --- a/factory/api/src/runs/loops.ts +++ b/factory/api/src/runs/loops.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import type { LoopLapClosedPayload, LoopStoppedPayload } from "@ndf-factory/contracts"; import type { AccountContext, AccountRecord, SeatRecord } from "../accounts/model.ts"; -import { ApiError, ConflictError, InvalidRequestError, NotFoundError } from "../errors.ts"; +import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError } from "../errors.ts"; import type { ProgressReader } from "../progress/read.ts"; import type { LoopHooks, RunControl } from "./control.ts"; import { @@ -22,6 +22,7 @@ import { type WaitReason, } from "./model.ts"; import type { RunsTable } from "./table.ts"; +import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts"; export const LOOP_NAME = /^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$/; export const MAX_MILESTONES = 10; @@ -182,7 +183,6 @@ export class LoopControl { // F1: 始める。マイルストーンの持ち分とループの行を 1 回で書き、続けて最初の周回を起こす(枠で待つときも 201) async startLoop(ctx: AccountContext, repositoryId: number, body: Record): Promise<{ loop_id: string; state: DevLoop["state"] }> { - const { table } = this.deps; const name = body.name; if (typeof name !== "string" || !LOOP_NAME.test(name)) throw new InvalidRequestError("ループの名前は英数字と - _ で 64 字までにしてください"); const milestones = body.milestones; @@ -196,6 +196,36 @@ export class LoopControl { throw new InvalidRequestError(`マイルストーンは 1〜${MAX_MILESTONES} 件、各 ${MAX_MILESTONE_LENGTH} 字までで , を含めずに選んでください`); } const budget = parseBudget(body.budget); + // 作成の冪等の鍵(#2384 の I14)。経路は必ず渡す。同じ鍵の 2 回目は 1 回目のループを返す + const idempotency: IdempotencyRef | undefined = + body.idempotency_key === undefined ? undefined : { userId: ctx.userId, operationId: "startLoop", key: parseIdempotencyKey(body.idempotency_key) }; + const replayed = idempotency ? await this.replayLoop(ctx, idempotency) : null; + if (replayed) return replayed; + let loop: DevLoop; + try { + loop = await this.insertLoop(ctx, repositoryId, name, milestones as string[], budget, idempotency); + } catch (err) { + // 同じ鍵の先行の要求が途中で作り終えていれば、名前・持ち分の断りより先にそのループを返す + const won = idempotency && err instanceof ApiError ? await this.replayLoop(ctx, idempotency) : null; + if (won) return won; + throw err; + } + // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52) + await this.startNextLap(ctx.accountId, loop.loopId, true); + const after = await this.deps.table.getLoop(ctx.accountId, loop.loopId); + return { loop_id: loop.loopId, state: after?.state ?? loop.state }; + } + + // ループの行とマイルストーンの持ち分を 1 回で書く。断るときは ApiError を投げる + private async insertLoop( + ctx: AccountContext, + repositoryId: number, + name: string, + milestones: string[], + budget: LoopBudget, + idempotency: IdempotencyRef | undefined, + ): Promise { + const { table } = this.deps; const registration = await table.getRegistration(ctx.accountId, repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const same = (await table.listLoops(ctx.accountId)).find((l) => l.repositoryId === repositoryId && l.name === name && !isTerminalLoop(l.state)); @@ -206,7 +236,7 @@ export class LoopControl { accountId: ctx.accountId, repositoryId, name, - milestones: milestones as string[], + milestones, budget, state: "between_laps", waitReason: null, @@ -226,7 +256,9 @@ export class LoopControl { updatedAt: now, lastRunAt: null, }; - const created = await table.createLoop(loop); + const created = await table.createLoop(loop, idempotency && claimOf(idempotency, loop.loopId, now)); + // 同じ鍵の作成が同時に走り、先に書いた方が勝った。勝ったループは呼び出し元が鍵から引き直す + if (created === "duplicate") throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください"); if (created !== "created") { const share = await table.getMilestoneShare(ctx.accountId, repositoryId, created.conflict); const other = share ? await table.getLoop(ctx.accountId, share.loopId) : null; @@ -236,10 +268,17 @@ export class LoopControl { loop_name: other?.name ?? null, }); } - // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52) - await this.startNextLap(ctx.accountId, loop.loopId, true); - const after = await table.getLoop(ctx.accountId, loop.loopId); - return { loop_id: loop.loopId, state: after?.state ?? loop.state }; + return loop; + } + + // 期限の中の同じ鍵が作ったループの今の状態 + private async replayLoop(ctx: AccountContext, ref: IdempotencyRef): Promise<{ loop_id: string; state: DevLoop["state"] } | null> { + const loopId = await this.deps.table.getIdempotent(ref, this.iso()); + if (!loopId) return null; + // 鍵は利用者ごとでアカウントをまたぐ。別のアカウントのループなら返さずに forbidden + const loop = await this.deps.table.getLoop(ctx.accountId, loopId); + if (!loop) throw new ForbiddenError(); + return { loop_id: loopId, state: loop.state }; } // 周回の開始(決定 2、I44・I52)。between_laps で今の実行が無いループだけを起こす。条件で落ちれば読み直して 3 回まで diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts index f17bbcb23..cd63b6635 100644 --- a/factory/api/src/runs/memory-table.ts +++ b/factory/api/src/runs/memory-table.ts @@ -17,6 +17,7 @@ import { type SprintRun, } from "./model.ts"; import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; const regKey = (a: string, r: number) => `${a}#${r}`; const shareKey = (a: string, r: number, k: string | number) => `${a}#${r}#${k}`; @@ -38,6 +39,8 @@ export class MemoryRunsTable implements RunsTable { readonly runnerSlots = new Map(); // # → 古い版の実行を数えに入れた回数(#2439 の I62) readonly legacySeqs = new Map(); + // 作成の冪等の鍵の行(IDEMP#<利用者>#<操作>#<鍵>)。TTL で消さない + readonly idempotency = new Map(); // Factory のオーガナイザー(#2439)。#(#)→ 行 readonly organizers = new Map(); readonly orgRequests = new Map(); @@ -145,6 +148,14 @@ export class MemoryRunsTable implements RunsTable { async createRun(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): Promise { await this.tick(); + const refused = this.refuseCreate(run, expected, guards); + if (refused) return refused; + this.commitCreate(run, job, expected, guards); + return "created"; + } + + // 作成を拒む理由。条件はこの順で確かめ、どれか 1 つでも当たれば何も書かない + private refuseCreate(run: SprintRun, expected: Slots, guards?: CreateRunGuards): CreateRunResult | null { const repoKey = regKey(run.accountId, run.repositoryId); if ((this.accountSlots.get(run.accountId) ?? 0) !== expected.account) return "slots"; if ((this.repositorySlots.get(repoKey) ?? 0) !== expected.repository) return "slots"; @@ -155,7 +166,14 @@ export class MemoryRunsTable implements RunsTable { if (guards.issues.some((n) => this.issueShares.has(shareKey(run.accountId, run.repositoryId, n)))) return "issue_in_use"; if (guards.milestoneChecks.some((t) => this.milestoneShares.has(shareKey(run.accountId, run.repositoryId, t)))) return "issue_in_loop"; if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed"; + if (guards.idempotency && !this.idempotencyFree(guards.idempotency)) return "duplicate"; } + return null; + } + + // 全条件が通った後の書き込み + private commitCreate(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): void { + const repoKey = regKey(run.accountId, run.repositoryId); this.accountSlots.set(run.accountId, expected.account + 1); this.repositorySlots.set(repoKey, expected.repository + 1); if (guards) { @@ -165,10 +183,10 @@ export class MemoryRunsTable implements RunsTable { } for (const n of guards.issues) this.issueShares.set(shareKey(run.accountId, run.repositoryId, n), run.sprintRunId); if (guards.loop) this.applyLoop(guards.loop); + if (guards.idempotency) this.idempotency.set(idempotencyPk(guards.idempotency), { ...guards.idempotency }); } this.runs.set(run.sprintRunId, structuredClone({ ...run, ...(guards ? { legacy: guards.legacy } : {}) })); this.jobs.set(job.jobId, structuredClone(job)); - return "created"; } async getRun(sprintRunId: string) { @@ -312,11 +330,24 @@ export class MemoryRunsTable implements RunsTable { return true; } - async createLoop(loop: DevLoop) { + // 期限の中の同じ鍵の行が無い(DynamoDB の attribute_not_exists(pk) OR ttl < :now と同じ) + private idempotencyFree(claim: IdempotencyClaim): boolean { + const row = this.idempotency.get(idempotencyPk(claim)); + return !row || row.ttl < epochSeconds(claim.createdAt); + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + await this.tick(); + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } + + async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim) { await this.tick(); for (const title of loop.milestones) { if (this.milestoneShares.has(shareKey(loop.accountId, loop.repositoryId, title))) return { conflict: title }; } + if (idempotency && !this.idempotencyFree(idempotency)) return "duplicate" as const; + if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); for (const title of loop.milestones) this.milestoneShares.set(shareKey(loop.accountId, loop.repositoryId, title), { loopId: loop.loopId, title }); this.loops.set(loop.loopId, structuredClone(loop)); return "created" as const; diff --git a/factory/api/src/runs/model.ts b/factory/api/src/runs/model.ts index 33a79a0b8..551965454 100644 --- a/factory/api/src/runs/model.ts +++ b/factory/api/src/runs/model.ts @@ -1,6 +1,7 @@ // 集約「スプリントの実行」「実行の枠」「開発ループ」と、同じ表に置くリポジトリの登録・持ち込んだキーの行(#2186・#2347 の設計の「データ構造」) import { createHash } from "node:crypto"; import type { AccountContext, ExecutionMode } from "../accounts/model.ts"; +import type { IdempotencyClaim } from "../idempotency.ts"; // awaiting_reset は local の利用上限のリセット時刻を待つ(終わりの状態でない。枠を数え、今のジョブは空。#2347 の決定 7) export const RUN_STATES = ["starting", "running", "awaiting_approval", "awaiting_reset", "done", "stopped", "failed", "cancelled"] as const; @@ -213,10 +214,12 @@ export interface CreateRunGuards { legacy: boolean; // ループの周回の開始。ループの行を条件つきで書く loop?: LoopWrite; + // 作成の冪等の鍵の行(手動の開始。#2384 の I14)。期限の中の同じ鍵の行があれば duplicate + idempotency?: IdempotencyClaim; } // release_running は、古い版の実行の開始で、リポジトリのまとめた配布が流れていた(#2439 の I62) -export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running"; +export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running" | "duplicate"; // マイルストーンの持ち分の行の鍵に使う、題の sha256 の先頭 32 字 export const milestoneHash = (title: string) => createHash("sha256").update(title, "utf8").digest("hex").slice(0, 32); diff --git a/factory/api/src/runs/table.ts b/factory/api/src/runs/table.ts index 231906837..bc84c3712 100644 --- a/factory/api/src/runs/table.ts +++ b/factory/api/src/runs/table.ts @@ -15,6 +15,7 @@ import type { Slots, SprintRun, } from "./model.ts"; +import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts"; // 終わりへの移り・承認待ち・リセット待ちに添える書き込み(決定 2。ループは実行と同じトランザクションで書く) export interface RunTransitionExtras { @@ -98,7 +99,10 @@ export interface RunsTable { // ---- 開発ループ(#2347) ---- // ループの行とマイルストーンの持ち分を 1 回で書く。持ち分が既にあれば、重なった題を返して書かない(I45) - createLoop(loop: DevLoop): Promise<"created" | { conflict: string }>; + // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate(#2384 の I14) + createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }>; + // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う + getIdempotent(ref: IdempotencyRef, now: string): Promise; getLoop(accountId: string, loopId: string): Promise; // アカウントのループの新しい順 listLoops(accountId: string): Promise; diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts index c49b7a038..292ba14be 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -1,31 +1,123 @@ +import { operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; +import { MemoryDelegationStore } from "../delegations/memory-store.ts"; +import { MemoryAuditLog } from "../audit/memory-log.ts"; +import { DelegationService } from "../delegations/service.ts"; import { createApiApp, type LogEntry } from "../handlers/api.ts"; import { createTokenKit, type TokenKit } from "./tokens.ts"; +import type { FactoryDeps } from "../routes/factory.ts"; +import { CredentialService, type SecretsPort } from "../credentials/service.ts"; +import { RepositoryService } from "../repositories/service.ts"; +import { memoryProgressReader } from "../progress/read.ts"; +import { LoopControl } from "../runs/loops.ts"; +import { createRunsHarness, type RunsHarness } from "./runs.ts"; + +// 秘密の置き場の偽物(値はメモリだけに置く) +export class FakeSecrets implements SecretsPort { + readonly values = new Map(); + private n = 0; + async create(name: string, value: string) { + const arn = `arn:aws:secretsmanager:us-east-1:111111111111:secret:${name}-${++this.n}`; + this.values.set(arn, value); + return arn; + } + async put(arn: string, value: string) { + this.values.set(arn, value); + } + async delete(arn: string) { + this.values.delete(arn); + } +} // テストで本文を読みやすくするため、json() の型を any にした応答 export type TestResponse = Omit & { json(): Promise }; +// 操作の呼び出し(#2384)。経路と query は操作の表から組む。items は本文の要素の配列 +export interface OpOptions { + user?: string; + idp?: "Google" | "GitHub"; + authorization?: string; + accountId?: string; + query?: Record; + items?: unknown; +} + +export function opRequest(operationId: OperationId, opts: OpOptions): { method: string; path: string; body: unknown } { + const op = operation(operationId); + return { method: op.method, path: operationUrl(op, opts.accountId ?? null, opts.query), body: opts.items }; +} + +// 要素 1 個の要求の結果を、今までの 1 件の応答と同じ形(状態と本文)で読む。要求全体を断ったときはその状態と本文 +export async function firstResult(res: TestResponse): Promise<{ status: number; body: any }> { + const data = await res.json(); + if (res.status !== 200 || !Array.isArray(data)) return { status: res.status, body: data }; + const r = data[0]; + if (r && "ok" in r) return r.ok ? { status: 200, body: r.value } : { status: r.status, body: { error: r.error } }; + return { status: 200, body: r }; +} + export interface TestApi { app: ReturnType; store: MemoryAccountStore; service: AccountService; + delegationStore: MemoryDelegationStore; + audit: MemoryAuditLog; + delegations: DelegationService; + // 時刻を進められる時計(委任の期限の確かめ) + clock: { now: Date }; + // factory を求めたときだけ。実行の表・GitHub・秘密の置き場の偽物 + h: RunsHarness | null; + secrets: FakeSecrets; tokens: TokenKit; logs: LogEntry[]; call(method: string, path: string, opts?: { user?: string; idp?: "Google" | "GitHub"; body?: unknown; authorization?: string }): Promise; + op(operationId: OperationId, opts?: OpOptions): Promise; } -export async function createTestApi(): Promise { +export async function createTestApi(opts: { factory?: boolean } = {}): Promise { const store = new MemoryAccountStore(); let n = 0; const service = new AccountService({ store, newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}` }); const tokens = await createTokenKit(); const logs: LogEntry[] = []; - const app = createApiApp(() => ({ verifier: tokens.verifier, accounts: service }), (e) => logs.push(e)); - return { + const clock = { now: new Date("2026-11-04T00:00:00.000Z") }; + const delegationStore = new MemoryDelegationStore(); + const audit = new MemoryAuditLog(); + const delegations = new DelegationService({ store: delegationStore, now: () => clock.now }); + const secrets = new FakeSecrets(); + const h = opts.factory ? await createRunsHarness() : null; + let factory: FactoryDeps | null = null; + if (h) { + const key = new TextEncoder().encode("proof-key-for-tests-0123456789ab"); + factory = { + repositories: new RepositoryService({ table: h.table, github: h.github, proofKey: async () => key }), + credentials: new CredentialService(h.table, secrets), + control: h.control, + loops: new LoopControl({ table: h.table, control: h.control, reader: memoryProgressReader(h.progress), now: () => h.clock.now }), + organizer: h.organizer, + table: h.table, + github: h.github, + candidateModel: async (_s, issues) => issues.map((i) => ({ number: i.number, selected: true, reason: "理由" })), + reader: memoryProgressReader(h.progress), + }; + } + const app = createApiApp( + () => ({ verifier: tokens.verifier, accounts: service, delegations, audit }), + (e) => logs.push(e), + () => null, + factory && (() => factory!), + ); + const api: TestApi = { app, store, service, + delegationStore, + audit, + delegations, + clock, + h, + secrets, tokens, logs, async call(method, path, opts = {}) { @@ -39,5 +131,10 @@ export async function createTestApi(): Promise { body: opts.body === undefined ? undefined : JSON.stringify(opts.body), })) as TestResponse; }, + op(operationId, opts = {}) { + const { method, path, body } = opRequest(operationId, opts); + return api.call(method, path, { ...opts, body }); + }, }; + return api; } diff --git a/factory/contracts/openapi/accounts.yaml b/factory/contracts/openapi/accounts.yaml index 45624838a..98ccc64d9 100644 --- a/factory/contracts/openapi/accounts.yaml +++ b/factory/contracts/openapi/accounts.yaml @@ -1,394 +1,303 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のアカウントの API - version: 0.1.0 + version: "0.1.0" description: >- - 画面向けのアカウント・席・実行の形態の経路(#2170)。どの経路も Cognito の access token を - Authorization: Bearer で要る。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: me - description: ログインした利用者 - - name: accounts - description: アカウント・席・実行の形態 -security: - - cognito: [] + 画面向けのアカウント・席・実行の形態の操作(#2170)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: me, description: ログインした利用者 }, { name: accounts, description: アカウント・席・実行の形態 }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /me: + "/me": get: tags: [me] operationId: getMe summary: 自分の利用者と、席を持つアカウントの一覧 description: 初めての要求では、Free・local の個人のアカウントを 1 つ作る。 + x-ndf-access: read responses: "200": - description: 利用者とアカウントの一覧 - content: - application/json: - schema: - $ref: "#/components/schemas/Me" - "401": - $ref: "#/components/responses/Unauthorized" - "503": - $ref: "#/components/responses/Unavailable" - /teams: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Me" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/teams": post: tags: [accounts] operationId: createTeam summary: チームのアカウントを作る description: 作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。 + x-ndf-access: human + x-ndf-keys: [idempotency_key] + x-ndf-id: account_id requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/CreateTeam" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateTeamItem" } } responses: - "201": - description: 作ったチームのアカウント + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "503": - $ref: "#/components/responses/Unavailable" - /accounts/{account_id}: - parameters: - - $ref: "#/components/parameters/AccountId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts": get: tags: [accounts] operationId: getAccount summary: アカウントの料金プラン・実行の形態・使える量・席 description: 個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。 + x-ndf-access: read + x-ndf-keys: [account_id] + parameters: + - name: account_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: アカウント + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - /accounts/{account_id}/execution-mode: - parameters: - - $ref: "#/components/parameters/AccountId" - put: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + patch: tags: [accounts] operationId: setExecutionMode summary: 実行の形態を変える description: アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。 + x-ndf-access: write + x-ndf-keys: [account_id] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SetExecutionMode" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/SetExecutionModeItem" } responses: "200": - description: 変えた後のアカウント + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/Conflict" - "503": - $ref: "#/components/responses/Unavailable" - /accounts/{account_id}/seats: - parameters: - - $ref: "#/components/parameters/AccountId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/seats": post: tags: [accounts] operationId: addSeat summary: 席を足す description: チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/AddSeat" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/AddSeat" } } responses: - "201": - description: 足した席 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Seat" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/Conflict" - "422": - $ref: "#/components/responses/UserNotFound" - /accounts/{account_id}/seats/{user_id}: - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/UserId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } patch: tags: [accounts] operationId: changeSeat summary: 席の種類を変える description: チームの持ち主だけが変えられる。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/ChangeSeat" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ChangeSeatItem" } } responses: "200": - description: 変えた後の席 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Seat" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/SeatNotFound" - "409": - $ref: "#/components/responses/Conflict" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } delete: tags: [accounts] operationId: removeSeat summary: 席を外す description: チームの持ち主だけが外せる。持ち主自身の席は外せない。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: user_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: - "204": - description: 外した - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/SeatNotFound" - "409": - $ref: "#/components/responses/Conflict" + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, bearerFormat: JWT, description: Cognito の利用者プールの access token } + delegation: type: http scheme: bearer - bearerFormat: JWT - description: Cognito の利用者プールの access token - parameters: - AccountId: - name: account_id - in: path - required: true - description: アカウントの ID - schema: - type: string - UserId: - name: user_id - in: path - required: true - description: 利用者の ID(Cognito の sub) - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: - InvalidRequest: - description: 要求の形が正しくない(invalid_request) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Unauthorized: - description: token が無い・壊れている・期限切れ・別の利用者プールかクライアントのもの(unauthorized) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Forbidden: + E400: description: >- - 席も持ち主でもない・アカウントが無い・持ち主の操作を持ち主でない者が求めた(forbidden)。 - アカウントの有無は出さない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - SeatNotFound: - description: 席が無い(seat_not_found)。持ち主と確かめた後だけ返す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Conflict: + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E403: description: >- - not_a_team(個人のアカウントへの席の操作)・seat_exists・owner_seat_protected・ - managed_not_allowed_on_free - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - UserNotFound: - description: 指した利用者がまだログインしていない(user_not_found) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Unavailable: - description: 保存に失敗した(account_unavailable)。時間をおいてやり直す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: - Plan: - type: string - enum: [free, pro, max, team] - SeatType: - type: string - enum: [standard, premium] + Plan: { type: string, enum: [free, pro, max, team] } + SeatType: { type: string, enum: [standard, premium] } ExecutionMode: type: string description: local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock) enum: [local, byok, managed] - Idp: - type: string - enum: [google, github] + Idp: { type: string, enum: [google, github] } AccountSummary: type: object required: [account_id, kind, name, plan, role, seat_type, execution_mode] properties: - account_id: - type: string - kind: - type: string - enum: [personal, team] - name: - type: [string, "null"] - description: チームの名前。個人のアカウントは null - plan: - $ref: "#/components/schemas/Plan" - role: - type: string - enum: [owner, member] + account_id: { type: string } + kind: { type: string, enum: [personal, team] } + name: { type: [string, "null"], description: チームの名前。個人のアカウントは null } + plan: { $ref: "#/components/schemas/Plan" } + role: { type: string, enum: [owner, member] } seat_type: description: チームのときだけ、要求した利用者の席の種類 - oneOf: - - $ref: "#/components/schemas/SeatType" - - type: "null" - execution_mode: - $ref: "#/components/schemas/ExecutionMode" + oneOf: [{ $ref: "#/components/schemas/SeatType" }, { type: "null" }] + execution_mode: { $ref: "#/components/schemas/ExecutionMode" } Limits: type: object required: [concurrent_sprint_runs, monthly_tokens] properties: - concurrent_sprint_runs: - type: integer - description: 同時に動かせるスプリントの実行の数 - monthly_tokens: - type: [integer, "null"] - description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない) + concurrent_sprint_runs: { type: integer, description: 同時に動かせるスプリントの実行の数 } + monthly_tokens: { type: [integer, "null"], description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない) } Seat: type: object required: [user_id, idp, seat_type, added_at] properties: - user_id: - type: string - idp: - oneOf: - - $ref: "#/components/schemas/Idp" - - type: "null" - seat_type: - $ref: "#/components/schemas/SeatType" - added_at: - type: string - format: date-time + user_id: { type: string } + idp: { oneOf: [{ $ref: "#/components/schemas/Idp" }, { type: "null" }] } + seat_type: { $ref: "#/components/schemas/SeatType" } + added_at: { type: string, format: date-time } AccountDetail: allOf: - - $ref: "#/components/schemas/AccountSummary" + - { $ref: "#/components/schemas/AccountSummary" } - type: object required: [limits, seats] properties: - limits: - description: 表に無い組では null - oneOf: - - $ref: "#/components/schemas/Limits" - - type: "null" + limits: { description: 表に無い組では null, oneOf: [{ $ref: "#/components/schemas/Limits" }, { type: "null" }] } seats: description: チームだけ。個人のアカウントは null - oneOf: - - type: array - items: - $ref: "#/components/schemas/Seat" - - type: "null" + oneOf: [{ type: array, items: { $ref: "#/components/schemas/Seat" } }, { type: "null" }] Me: type: object required: [user_id, idp, personal_account_id, accounts] properties: - user_id: - type: string - idp: - $ref: "#/components/schemas/Idp" - personal_account_id: - type: string - accounts: - type: array - items: - $ref: "#/components/schemas/AccountSummary" - CreateTeam: - type: object - required: [name, seat_type] - properties: - name: - type: string - minLength: 1 - maxLength: 64 - seat_type: - $ref: "#/components/schemas/SeatType" - SetExecutionMode: - type: object - required: [execution_mode] - properties: - execution_mode: - $ref: "#/components/schemas/ExecutionMode" + user_id: { type: string } + idp: { $ref: "#/components/schemas/Idp" } + personal_account_id: { type: string } + accounts: { type: array, items: { $ref: "#/components/schemas/AccountSummary" } } AddSeat: type: object required: [user_id, seat_type] properties: - user_id: - type: string - minLength: 1 - maxLength: 128 - seat_type: - $ref: "#/components/schemas/SeatType" - ChangeSeat: - type: object - required: [seat_type] - properties: - seat_type: - $ref: "#/components/schemas/SeatType" + user_id: { type: string, minLength: 1, maxLength: 128 } + seat_type: { $ref: "#/components/schemas/SeatType" } Error: type: object required: [error] @@ -397,9 +306,43 @@ components: type: object required: [code, message] properties: - code: - type: string - description: 機械の値 - message: - type: string - description: 利用者に出す文 + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + CreateTeamItem: + type: object + required: [idempotency_key, name, seat_type] + properties: + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + name: { type: string, minLength: 1, maxLength: 64 } + seat_type: { $ref: "#/components/schemas/SeatType" } + SetExecutionModeItem: + type: object + required: [account_id, execution_mode] + properties: + account_id: { type: string, minLength: 1 } + execution_mode: { $ref: "#/components/schemas/ExecutionMode" } + ChangeSeatItem: + type: object + required: [user_id, seat_type] + properties: + user_id: { type: string, minLength: 1, maxLength: 128 } + seat_type: { $ref: "#/components/schemas/SeatType" } + Removed: { type: object, required: [removed], properties: { removed: { const: true } } } diff --git a/factory/contracts/openapi/delegations.yaml b/factory/contracts/openapi/delegations.yaml new file mode 100644 index 000000000..67c8fa696 --- /dev/null +++ b/factory/contracts/openapi/delegations.yaml @@ -0,0 +1,150 @@ +openapi: "3.1.0" +info: + title: Neural Development Factory の委任の資格情報の API + version: "0.1.0" + description: >- + 人が画面で委任の資格情報を発行し、一覧で見て、取り消す操作(#2384)。委任は利用者に属し、アカウントに属さない。発行と取り消しは人だけが確定でき、委任の資格情報からは呼べない。一覧は委任の資格情報でも読める。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: delegations, description: エージェントへの委任 }] +security: [{ cognito: [] }, { delegation: [] }] +paths: + /delegations: + get: + tags: [delegations] + operationId: listDelegations + summary: 自分が発行した委任の資格情報の一覧 + description: 新しい順に 100 件まで。値と秘密のハッシュは返さない。 + x-ndf-access: read + responses: + "200": + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Delegation" } } } } + "401": { $ref: "#/components/responses/E401" } + "503": { $ref: "#/components/responses/E503" } + post: + tags: [delegations] + operationId: createDelegation + summary: 委任の資格情報を発行する + description: >- + 値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。 + x-ndf-access: human + x-ndf-keys: [idempotency_key] + x-ndf-id: delegation.credential_id + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateDelegationItem" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/DelegationIssued" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + delete: + tags: [delegations] + operationId: revokeDelegation + summary: 委任の資格情報を取り消す + description: 取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。 + x-ndf-access: human + x-ndf-keys: [credential_id] + parameters: + - name: credential_id + in: query + required: true + style: form + explode: true + description: 要素の鍵。繰り返して 1〜100 個を指す + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Delegation" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } +components: + securitySchemes: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: + type: http + scheme: bearer + description: 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない + responses: + E400: { description: 要求全体の形が違う。どの要素も処理しない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E401: { description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E403: { description: 委任の資格情報で人だけの操作を呼んだ(human_only), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E503: { description: 保存か操作の記録に失敗した。時間をおいてやり直す, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: { code: { type: string, description: 機械の値 }, message: { type: string, description: 利用者に出す文 }, details: { type: object } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: { code: { type: string }, message: { type: string }, details: { type: object } } + DelegationScope: + type: string + description: read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない + enum: [read, read_write] + CreateDelegationItem: + type: object + required: [client_name, scope, expires_in_seconds, idempotency_key] + additionalProperties: false + properties: + client_name: { type: string, minLength: 1, maxLength: 64, description: 人が付けるクライアントの名前。操作の記録には写さない } + scope: { $ref: "#/components/schemas/DelegationScope" } + expires_in_seconds: { type: integer, minimum: 300, maximum: 3600 } + idempotency_key: { type: string, pattern: "^[0-9A-Za-z-]{1,64}$", description: 作成の冪等の鍵 } + Delegation: + type: object + required: [credential_id, client_name, scope, created_at, expires_at, revoked_at, state] + properties: + credential_id: { type: string } + client_name: { type: string } + scope: { $ref: "#/components/schemas/DelegationScope" } + created_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } + revoked_at: { type: ["string", "null"], format: date-time } + state: { type: string, enum: [active, expired, revoked] } + DelegationIssued: + type: object + required: [delegation, credential] + properties: + delegation: { $ref: "#/components/schemas/Delegation" } + credential: { type: ["string", "null"], description: 委任の資格情報の値。発行の結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は null } diff --git a/factory/contracts/openapi/loops.yaml b/factory/contracts/openapi/loops.yaml index 46b3da320..9c6e6760c 100644 --- a/factory/contracts/openapi/loops.yaml +++ b/factory/contracts/openapi/loops.yaml @@ -1,250 +1,247 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory の開発ループの API - version: 0.1.0 + version: "0.1.0" description: >- - リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る画面向けの経路(#2347)。 - どの経路も Cognito の access token を Authorization: Bearer で要る。ループの accountId が一致しなければ 403、無ければ 404 loop_not_found。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: loops - description: 開発ループ -security: - - cognito: [] + リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る操作(#2347)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ループの accountId が一致しない要素は forbidden、無い要素は loop_not_found(404)の結果になる。人の判断待ちへの答えは人だけが確定できる。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: loops, description: 開発ループ }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/repositories/{repositoryId}/loops: + "/accounts/{accountId}/loops": post: tags: [loops] operationId: startLoop summary: 開発ループを始める description: >- - マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 - 枠で待つときも 201(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると - 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。 + x-ndf-access: write + x-ndf-keys: [idempotency_key] + x-ndf-id: loop_id + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopStartRequest" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopStartItem" } } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStarted" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStarted" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } get: tags: [loops] operationId: listLoops summary: リポジトリの開発ループの一覧 description: 新しい順に 50 件。 + x-ndf-access: read parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } } responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/LoopList" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/loops/{loopId}: - get: - tags: [loops] - operationId: getLoop - summary: 開発ループの詳細 - description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/LoopDetail" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/LoopRow" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } patch: tags: [loops] operationId: updateLoopBudget summary: 開発ループの予算を変える description: 終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopBudgetUpdate" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopBudgetItem" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopRow" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/detail": + get: + tags: [loops] + operationId: getLoop + summary: 開発ループの詳細 + description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。 + x-ndf-access: read + x-ndf-keys: [loop_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: loop_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopRow" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/cancel: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/cancel": post: tags: [loops] operationId: cancelLoop summary: 開発ループを止める description: 動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStateResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/next-lap: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/next-lap": post: tags: [loops] operationId: startNextLap summary: 次の周回を始める(形態 local) - description: 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + description: >- + 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。 + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } } responses: - "202": - description: 受け付けた + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStateResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/answers: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/answers": post: tags: [loops] operationId: answerLoopPending summary: 人の判断待ちの課題に答える - description: 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + description: >- + 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。 + x-ndf-access: human + x-ndf-keys: [loop_id, issue] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopAnswer" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopAnswerItem" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopPendingList" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopPendingResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: type: http scheme: bearer - description: Cognito の access token - parameters: - AccountId: - name: accountId - in: path - required: true - schema: - type: string - RepositoryId: - name: repositoryId - in: path - required: true - schema: - type: string - LoopId: - name: loopId - in: path - required: true - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -254,65 +251,50 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } - LoopName: - type: string - pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" + LoopName: { type: string, pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" } Budget: type: object additionalProperties: false properties: - laps: { type: ["number", "null"], exclusiveMinimum: 0 } - usd: { type: ["number", "null"], exclusiveMinimum: 0 } - hours: { type: ["number", "null"], exclusiveMinimum: 0 } - LoopState: - type: string - enum: [running, between_laps, awaiting_approval, stopped, cancelled] - LoopStartRequest: - type: object - required: [name, milestones] - additionalProperties: false - properties: - name: { $ref: "#/components/schemas/LoopName" } - milestones: - type: array - minItems: 1 - maxItems: 10 - uniqueItems: true - items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" } - budget: { $ref: "#/components/schemas/Budget" } + laps: { type: [number, "null"], exclusiveMinimum: 0 } + usd: { type: [number, "null"], exclusiveMinimum: 0 } + hours: { type: [number, "null"], exclusiveMinimum: 0 } + LoopState: { type: string, enum: [running, between_laps, awaiting_approval, stopped, cancelled] } LoopStarted: type: object required: [loop_id, state] - properties: - loop_id: { type: string } - state: { type: string, enum: [running, between_laps, stopped] } + properties: { loop_id: { type: string }, state: { type: string, enum: [running, between_laps, stopped] } } LoopRow: type: object required: - [loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail] + - loop_id + - name + - milestones + - state + - wait_reason + - laps_closed + - cost_usd + - budget + - budget_remaining + - last_run_at + - stop_condition + - stop_detail properties: loop_id: { type: string } name: { $ref: "#/components/schemas/LoopName" } milestones: { type: array, items: { type: string } } state: { $ref: "#/components/schemas/LoopState" } - wait_reason: { type: ["string", "null"], enum: [slots, legacy_version, user_start, null] } + wait_reason: { type: [string, "null"], enum: [slots, legacy_version, user_start, null] } laps_closed: { type: integer, minimum: 0 } cost_usd: { type: number, minimum: 0 } budget: { $ref: "#/components/schemas/Budget" } budget_remaining: { $ref: "#/components/schemas/Budget" } - last_run_at: { type: ["string", "null"], format: date-time } - stop_condition: { type: ["string", "null"] } - stop_detail: { type: ["string", "null"] } - LoopList: - type: object - required: [loops] - properties: - loops: - type: array - items: { $ref: "#/components/schemas/LoopRow" } + last_run_at: { type: [string, "null"], format: date-time } + stop_condition: { type: [string, "null"] } + stop_detail: { type: [string, "null"] } LoopPending: type: object required: [number, reason, lap, answer] @@ -321,11 +303,11 @@ components: reason: { type: string } lap: { type: integer, minimum: 1 } answer: - type: ["object", "null"] + type: [object, "null"] required: [decision, pr] properties: decision: { type: string, enum: [approved, rejected] } - pr: { type: ["integer", "null"], minimum: 1 } + pr: { type: [integer, "null"], minimum: 1 } LoopDetail: type: object required: [loop, laps, runs, pending] @@ -339,8 +321,8 @@ components: properties: lap: { type: integer, minimum: 1 } issues: { type: array, items: { type: integer } } - result: { type: string, enum: ["完了", "失敗", "人へ戻した"] } - version: { type: ["string", "null"] } + result: { type: string, enum: [完了, 失敗, 人へ戻した] } + version: { type: [string, "null"] } cost_usd: { type: number, minimum: 0 } seconds: { type: number, minimum: 0 } runs: @@ -351,38 +333,73 @@ components: properties: sprint_run_id: { type: string } lap: { type: integer, minimum: 1 } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } - finish_reason: { type: ["string", "null"], enum: [section_limit, usage_limit, null] } - resume_at: { type: ["string", "null"], format: date-time } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } + finish_reason: { type: [string, "null"], enum: [section_limit, usage_limit, null] } + resume_at: { type: [string, "null"], format: date-time } cost_usd: { type: number, minimum: 0 } sections: { type: integer, minimum: 0 } - pending: - type: array - items: { $ref: "#/components/schemas/LoopPending" } - LoopBudgetUpdate: + pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } } + LoopStateResult: type: object - required: [budget] + required: [state] + properties: { state: { $ref: "#/components/schemas/LoopState" } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + LoopStartItem: + type: object + required: [repository_id, idempotency_key, name, milestones] additionalProperties: false properties: + repository_id: { type: integer, minimum: 1 } + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + name: { $ref: "#/components/schemas/LoopName" } + milestones: + type: array + minItems: 1 + maxItems: 10 + uniqueItems: true + items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" } budget: { $ref: "#/components/schemas/Budget" } - LoopStateResult: + LoopBudgetItem: type: object - required: [state] - properties: - state: { $ref: "#/components/schemas/LoopState" } - LoopAnswer: + required: [loop_id, budget] + additionalProperties: false + properties: { loop_id: { type: string, minLength: 1 }, budget: { $ref: "#/components/schemas/Budget" } } + LoopRef: + type: object + required: [loop_id] + additionalProperties: false + properties: { loop_id: { type: string, minLength: 1 } } + LoopAnswerItem: type: object - required: [issue, decision] + required: [loop_id, issue, decision] additionalProperties: false properties: + loop_id: { type: string, minLength: 1 } issue: { type: integer, minimum: 1 } decision: { type: string, enum: [approved, rejected] } pr: { type: integer, minimum: 1 } - LoopPendingList: + LoopPendingResult: type: object - required: [pending] + required: [loop_id, pending] properties: - pending: - type: array - items: { $ref: "#/components/schemas/LoopPending" } + loop_id: { type: string } + pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } } diff --git a/factory/contracts/openapi/organizer.yaml b/factory/contracts/openapi/organizer.yaml index 4c9eae1e7..98f0d80ff 100644 --- a/factory/contracts/openapi/organizer.yaml +++ b/factory/contracts/openapi/organizer.yaml @@ -1,154 +1,126 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のオーガナイザーの API - version: 0.1.0 + version: "0.1.0" description: >- - 同じリポジトリで並行する実行の承認と問いの一覧、まとめた配布の履歴、止まった配布を解く操作(#2439)。 - どの経路も Cognito の access token を Authorization: Bearer で要る。アカウントのリポジトリでなければ 403、登録が無ければ 404。 - 一覧と履歴は読むだけで、承認の依頼の答えを確定する経路はこのファイルに無い(I66)。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: organizer - description: Factory のオーガナイザー -security: - - cognito: [] + Factory のオーガナイザーの操作(#2439)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。止まった配布を解く操作は人だけが確定できる(オーガナイザーの I66)。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: organizer, description: Factory のオーガナイザー }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/repositories/{repositoryId}/approval-requests: + "/accounts/{accountId}/approval-requests": get: tags: [organizer] operationId: listRepositoryApprovalRequests summary: リポジトリの承認と問いの一覧 description: >- - 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 - 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + x-ndf-access: read + x-ndf-keys: [repository_id] parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ApprovalRequestList" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/repositories/{repositoryId}/releases: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ApprovalRequestList" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/releases": get: tags: [organizer] operationId: listRepositoryReleases summary: まとめた配布の履歴 - description: 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + description: >- + 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + x-ndf-access: read parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" - - name: limit - in: query - required: false - schema: { type: integer, minimum: 1, maximum: 100 } + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 } } responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/ReleaseHistory" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve: + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/releases/resolve": post: tags: [organizer] operationId: resolveStalledRelease summary: 止まった配布を解く description: >- - 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 - 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" - - name: seq - in: path - required: true - schema: { type: integer, minimum: 1 } + 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。 + x-ndf-access: human + x-ndf-keys: [repository_id, seq] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/ResolveRequest" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ResolveItem" } } responses: - "202": - description: 受けた + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ResolveResult" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ResolveResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: type: http scheme: bearer - description: Cognito の access token - parameters: - AccountId: - name: accountId - in: path - required: true - schema: - type: string - RepositoryId: - name: repositoryId - in: path - required: true - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -158,16 +130,16 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } OrganizerSummary: type: object required: [current_seq, wait_reason, wait_detail] properties: - current_seq: { type: ["integer", "null"], description: 流れているまとめた配布の連番。null は流れていない } - wait_reason: { type: ["string", "null"], enum: [external_release_open, legacy_run, null] } - wait_detail: { type: ["string", "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) } + current_seq: { type: [integer, "null"], description: 流れているまとめた配布の連番。null は流れていない } + wait_reason: { type: [string, "null"], enum: [external_release_open, legacy_run, null] } + wait_detail: { type: [string, "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) } ReleaseLane: type: object required: [lane, requested, prs] @@ -180,8 +152,8 @@ components: required: [seq, k, sha, lanes, added_prs, choices] properties: seq: { type: integer } - k: { type: ["integer", "null"], description: 最後の提示の番号。提示の前に止まった配布は null } - sha: { type: ["string", "null"] } + k: { type: [integer, "null"], description: 最後の提示の番号。提示の前に止まった配布は null } + sha: { type: [string, "null"] } lanes: { type: array, items: { $ref: "#/components/schemas/ReleaseLane" } } added_prs: { type: array, items: { type: integer } } choices: { type: array, items: { type: string, enum: [continue, reject] } } @@ -193,15 +165,16 @@ components: sprint_run_id: { type: string } loop_id: { type: string } approval_request_id: { type: string } - pr_url: { type: ["string", "null"] } + pr_url: { type: [string, "null"] } number: { type: integer, description: ループの人の判断待ちの課題の番号 } reason: { type: string } created_at: { type: string, format: date-time } release: { $ref: "#/components/schemas/ReleaseSummary" } ApprovalRequestList: type: object - required: [organizer, items] + required: [repository_id, organizer, items] properties: + repository_id: { type: integer } organizer: { $ref: "#/components/schemas/OrganizerSummary" } items: { type: array, items: { $ref: "#/components/schemas/ApprovalRequestItem" } } Presentation: @@ -217,25 +190,25 @@ components: items: type: object required: [lane, requested] - properties: - lane: { type: string } - requested: { type: boolean } + properties: { lane: { type: string }, requested: { type: boolean } } added_prs: { type: array, items: { type: integer } } at: { type: string, format: date-time } ReleaseResult: type: object required: [version, prod_pr_url, sha, prs] properties: - version: { type: ["string", "null"] } - prod_pr_url: { type: ["string", "null"] } - sha: { type: ["string", "null"] } + version: { type: [string, "null"] } + prod_pr_url: { type: [string, "null"] } + sha: { type: [string, "null"] } prs: { type: array, items: { type: integer } } ReleaseLogEntry: type: object required: [at, kind, detail] properties: at: { type: string, format: date-time } - kind: { type: string, enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] } + kind: + type: string + enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] detail: { type: object } ReleaseBatch: type: object @@ -245,23 +218,34 @@ components: state: { type: string, enum: [composed, presented, represented, confirmed, stalled, released, rejected] } release_run_id: { type: string } presentations: { type: array, items: { $ref: "#/components/schemas/Presentation" } } - confirmed_k: { type: ["integer", "null"] } + confirmed_k: { type: [integer, "null"] } result: { oneOf: [{ $ref: "#/components/schemas/ReleaseResult" }, { type: "null" }] } log: { type: array, items: { $ref: "#/components/schemas/ReleaseLogEntry" } } - ReleaseHistory: - type: object - required: [items] - properties: - items: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } - ResolveRequest: - type: object - required: [choice] - properties: - choice: { type: string, enum: [continue, reject] } - body: { type: string, maxLength: 4000 } ResolveResult: type: object required: [seq, state] properties: seq: { type: integer } state: { type: string, enum: [composed, represented, confirmed, rejected] } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + ResolveItem: + type: object + required: [repository_id, seq, choice] + properties: + repository_id: { type: integer, minimum: 1 } + seq: { type: integer, minimum: 1 } + choice: { type: string, enum: [continue, reject] } + body: { type: string, maxLength: 4000 } diff --git a/factory/contracts/openapi/runs.yaml b/factory/contracts/openapi/runs.yaml index 9aa7ff265..528694657 100644 --- a/factory/contracts/openapi/runs.yaml +++ b/factory/contracts/openapi/runs.yaml @@ -1,686 +1,622 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のスプリントの実行の API - version: 0.1.0 + version: "0.1.0" description: >- - 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の経路と、ランナー向けの経路(#2186)。 - 画面向けは Cognito の access token、ランナー向け(/run-jobs/*)は実行ジョブの token を Authorization: Bearer で要る。 - 再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE + 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の操作と、ランナー向けの経路(#2186)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ランナー向け(/run-jobs/*)は実行ジョブの token を要り、配列の規則の外にある。再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin) + - url: "https://api.example.com" + description: >- + API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin) tags: - - name: repositories - description: リポジトリの登録 - - name: credentials - description: キーの持ち込み - - name: sprint-runs - description: 課題の候補とスプリントの実行 - - name: run-jobs - description: ランナー向けの経路 -security: - - cognito: [] + - { name: repositories, description: リポジトリの登録 } + - { name: credentials, description: キーの持ち込み } + - { name: sprint-runs, description: 課題の候補とスプリントの実行 } + - { name: run-jobs, description: ランナー向けの経路 } +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/github/install-url: + "/run-jobs/github-token": + post: + tags: [run-jobs] + operationId: postRunJobGitHubToken + summary: 導入の token の渡し直し + description: >- + 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/GitHubToken" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "409": { $ref: "#/components/responses/E409" } + "/run-jobs/state": + post: + tags: [run-jobs] + operationId: postRunJobState + summary: 状態の置き場の署名つき URL + description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。 + security: [{ runJob: [] }] + requestBody: + required: true + content: { "application/json": { schema: { $ref: "#/components/schemas/StateRequest" } } } + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/SignedUrl" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "/run-jobs/approval-requests/{approvalRequestId}/answer": + get: + tags: [run-jobs] + operationId: getRunJobApprovalAnswer + summary: 承認の依頼への人の答え + description: >- + resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、 その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。 + security: [{ runJob: [] }] + parameters: [{ name: approvalRequestId, in: path, required: true, schema: { type: string } }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/ApprovalAnswer" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "409": { $ref: "#/components/responses/E409" } + "/run-jobs/organizer/answers": + get: + tags: [run-jobs] + operationId: getRunJobOrganizerAnswers + summary: 配布の依頼の答えと配布済みの範囲 + description: >- + 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerAnswers" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "/run-jobs/organizer/batch": + get: + tags: [run-jobs] + operationId: getRunJobOrganizerBatch + summary: 配布の実行のまとめた配布の中身 + description: >- + 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerBatch" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "/accounts/{accountId}/github/install-url": get: tags: [repositories] operationId: getInstallUrl summary: GitHub App の導入の画面の URL - description: 利用者が自分のリポジトリへ GitHub App を入れる画面の URL を返す。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + description: GitHub App の導入の URL。要素 1 個の配列で返す。 + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Url" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/github/authorize-url: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/github/authorize-url": get: tags: [repositories] operationId: getAuthorizeUrl summary: GitHub App の利用者の認可の URL description: state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Url" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/github/candidates: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/github/candidates": post: tags: [repositories] operationId: postRepositoryCandidates summary: 認可の戻りから登録の候補を返す description: 導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: write + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/CandidatesRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/CandidatesRequest" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/RepositoryCandidates" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/repositories: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/RepositoryCandidates" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/repositories": get: tags: [repositories] operationId: listRepositories summary: 登録したリポジトリの一覧 description: リポジトリごとの終わりの状態でないスプリントの実行の数を添える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/RepositoryList" - "403": - $ref: "#/components/responses/E403" + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Repository" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } post: tags: [repositories] operationId: registerRepository summary: リポジトリを登録する description: 候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/RegisterRequest" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + patch: + tags: [repositories] + operationId: updateRepository + summary: リポジトリの同時の実行の数を宣言する + description: "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。" + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/RegisterRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/RepositoryUpdateItem" } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Repository" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/model-credential: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + delete: + tags: [repositories] + operationId: removeRepository + summary: リポジトリの登録を外す + description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。 + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/model-credential": get: tags: [credentials] operationId: getModelCredential summary: 持ち込んだキーの有無 description: 登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/ModelCredential" - "403": - $ref: "#/components/responses/E403" + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ModelCredential" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } put: tags: [credentials] operationId: putModelCredential summary: キーを保存・差し替える description: Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: human + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ModelCredentialInput" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/ModelCredentialInput" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ModelCredential" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ModelCredential" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } delete: tags: [credentials] operationId: deleteModelCredential summary: 持ち込んだキーを消す - description: 復元の期間を置かずに消す(決定 14)。無くても 204。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + description: 復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。 + x-ndf-access: human + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: - "204": - description: 成功 - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/repositories/{repositoryId}/issue-candidates: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/issue-candidates": post: tags: [sprint-runs] operationId: postIssueCandidates summary: AI が選んだ課題の候補 - description: 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string + description: >- + 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。 + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/IssueCandidatesRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/IssueCandidatesItem" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/IssueCandidates" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/sprint-runs: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/IssueCandidates" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs": post: tags: [sprint-runs] operationId: startSprintRun summary: スプリントの実行を始める description: >- - 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 - 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 - 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 - 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。 + x-ndf-access: write + x-ndf-keys: [idempotency_key] + x-ndf-id: sprint_run_id + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/StartRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/StartSprintRunItem" } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SprintRunStarted" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunStarted" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } get: tags: [sprint-runs] operationId: listSprintRuns summary: スプリントの実行の一覧 - description: 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 + description: >- + 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 + x-ndf-access: read parameters: - - name: accountId - in: path - required: true - schema: - type: string + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } - name: repository_id in: query required: false - schema: - type: integer - minimum: 1 + description: 省くとアカウントの新しい順の 50 件 + schema: { type: integer, minimum: 1 } responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/SprintRunList" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}: + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/SprintRunRow" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/detail": get: tags: [sprint-runs] operationId: getSprintRunDetail summary: スプリントの詳細 description: >- - フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 - 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の経路で 1 件ずつ読む。 - step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 - 無い実行・アカウントに属さない実行も、他人のアカウントと同じ本文の 403。 + フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [sprint_run_id] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: sprint_run_id + in: query required: true - schema: - type: string + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SprintRunDetail" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/steps": get: tags: [sprint-runs] operationId: getStepDetail summary: 工程の中身 description: >- - 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 - stepKey は事象の idempotency_key(URL の符号化をする)。記録の無い項目は null。 - 無い工程・別の実行の工程も、他人のアカウントと同じ本文の 403。 + 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [step_key] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: sprint_run_id + in: query required: true - schema: - type: string - - name: stepKey - in: path + description: 工程を持つ実行。他人の実行なら要求全体が 403 + schema: { type: string } + - name: step_key + in: query required: true - schema: - type: string + style: form + explode: true + description: "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/StepDetail" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/repositories/{repositoryId}/backlog: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/StepDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/backlogs": get: tags: [repositories] operationId: getBacklog summary: バックログ description: >- - リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 - 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリも、他人のアカウントと同じ本文の 403。 + リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [repository_id] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query required: true - schema: - type: integer - minimum: 1 + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Backlog" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}/cancel: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Backlog" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/cancel": post: tags: [sprint-runs] operationId: cancelSprintRun summary: スプリントの実行を取り消す - description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path - required: true - schema: - type: string - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/CancelResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /run-jobs/github-token: - post: - tags: [run-jobs] - operationId: postRunJobGitHubToken - summary: 導入の token の渡し直し - description: 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。 - security: - - runJob: [] - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/GitHubToken" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "409": - $ref: "#/components/responses/E409" - /run-jobs/state: - post: - tags: [run-jobs] - operationId: postRunJobState - summary: 状態の置き場の署名つき URL - description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。 - security: - - runJob: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/StateRequest" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/SignedUrl" - "400": - $ref: "#/components/responses/E400" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /run-jobs/approval-requests/{approvalRequestId}/answer: - get: - tags: [run-jobs] - operationId: getRunJobApprovalAnswer - summary: 承認の依頼への人の答え description: >- - resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、 - その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない - (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。 - security: - - runJob: [] - parameters: - - name: approvalRequestId - in: path - required: true - schema: { type: string } - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/ApprovalAnswer" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/repositories/{repositoryId}: - patch: - tags: [repositories] - operationId: updateRepository - summary: リポジトリの同時の実行の数を宣言する - description: 料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string + 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 + x-ndf-access: write + x-ndf-keys: [sprint_run_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/RepositoryUpdate" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Repository" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - delete: - tags: [repositories] - operationId: removeRepository - summary: リポジトリの登録を外す - description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string - responses: - "204": - description: 成功 - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /run-jobs/organizer/answers: - get: - tags: [run-jobs] - operationId: getRunJobOrganizerAnswers - summary: 配布の依頼の答えと配布済みの範囲 - description: >- - 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 - リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。 - security: - - runJob: [] - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/OrganizerAnswers" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - /run-jobs/organizer/batch: - get: - tags: [run-jobs] - operationId: getRunJobOrganizerBatch - summary: 配布の実行のまとめた配布の中身 - description: >- - 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 - このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。 - security: - - runJob: [] + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/SprintRunRef" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/OrganizerBatch" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/CancelResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: - type: http - scheme: bearer - description: Cognito の access token - runJob: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + runJob: { type: http, scheme: bearer, description: 実行ジョブの token } + delegation: type: http scheme: bearer - description: 実行ジョブの token + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E401: - description: 認証できない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E404: { description: 見つからない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E409: { description: 状態が合わない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } E503: - description: 時間をおいてやり直す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -690,20 +626,14 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } - Url: - type: object - required: [url] - properties: - url: { type: string, format: uri } + Url: { type: object, required: [url], properties: { url: { type: string, format: uri } } } CandidatesRequest: type: object required: [code, state] - properties: - code: { type: string, minLength: 1 } - state: { type: string, minLength: 1 } + properties: { code: { type: string, minLength: 1 }, state: { type: string, minLength: 1 } } RepositoryCandidates: type: object required: [proof, candidates] @@ -722,9 +652,7 @@ components: RegisterRequest: type: object required: [proof, repository_id] - properties: - proof: { type: string } - repository_id: { type: integer, minimum: 1 } + properties: { proof: { type: string }, repository_id: { type: integer, minimum: 1 } } Repository: type: object required: [repository_id, full_name, installation_id, registered_at] @@ -734,36 +662,21 @@ components: installation_id: { type: integer } registered_at: { type: string, format: date-time } active_sprint_runs: { type: integer, minimum: 0 } - max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 } - RepositoryUpdate: - type: object - required: [max_concurrent_runs] - additionalProperties: false - properties: - max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 } - RepositoryList: - type: object - required: [repositories] - properties: - repositories: - type: array - items: { $ref: "#/components/schemas/Repository" } + max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 } ModelCredential: type: object required: [registered, kind, updated_at, external_id] properties: registered: { type: boolean } - kind: { type: ["string", "null"], enum: [anthropic_api_key, aws_role, null] } - updated_at: { type: ["string", "null"], format: date-time } + kind: { type: [string, "null"], enum: [anthropic_api_key, aws_role, null] } + updated_at: { type: [string, "null"], format: date-time } external_id: { type: string } ModelCredentialInput: oneOf: - type: object required: [kind, api_key] additionalProperties: false - properties: - kind: { const: anthropic_api_key } - api_key: { type: string, minLength: 1, maxLength: 512 } + properties: { kind: { const: anthropic_api_key }, api_key: { type: string, minLength: 1, maxLength: 512 } } - type: object required: [kind, role_arn, region] additionalProperties: false @@ -771,19 +684,13 @@ components: kind: { const: aws_role } role_arn: { type: string, maxLength: 2048, pattern: "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$" } region: { type: string, maxLength: 32, pattern: "^[a-z]{2}(-[a-z]+)+-\\d$" } - IssueCandidatesRequest: - type: object - properties: - exclude: - type: array - items: { type: integer, minimum: 1 } IssueCandidates: type: object required: [source, milestone, candidates, note] properties: source: { type: string, enum: [ranking, recent] } - milestone: { type: ["string", "null"] } - note: { type: ["string", "null"] } + milestone: { type: [string, "null"] } + note: { type: [string, "null"] } candidates: type: array items: @@ -794,70 +701,29 @@ components: title: { type: string } selected: { type: boolean } reason: { type: string, minLength: 1 } - StartRequest: - type: object - required: [repository_id, issues] - properties: - repository_id: { type: integer, minimum: 1 } - issues: - type: array - minItems: 1 - maxItems: 20 - uniqueItems: true - items: { type: integer, minimum: 1 } SprintRunStarted: type: object required: [sprint_run_id, state] - properties: - sprint_run_id: { type: string } - state: { const: starting } - SprintRunList: - type: object - required: [sprint_runs] - properties: - sprint_runs: - type: array - items: - type: object - required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at] - properties: - sprint_run_id: { type: string } - repository_id: { type: integer } - issues: { type: array, items: { type: integer } } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } - created_at: { type: string, format: date-time } - repo: { type: string, description: owner/name } - current_phase: - type: ["string", "null"] - description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す - current_step: { type: ["string", "null"], description: 最後の step.recorded のステップ } - last_event_at: { type: ["string", "null"], format: date-time, description: 最後の事象の時刻 } + properties: { sprint_run_id: { type: string }, state: { const: starting } } PullRequestLink: type: object required: [repo, number, url] - properties: - repo: { type: string } - number: { type: integer } - url: { type: string, format: uri } + properties: { repo: { type: string }, number: { type: integer }, url: { type: string, format: uri } } StepRow: type: object required: [key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details] properties: key: { type: string, description: 事象の idempotency_key } at: { type: string, format: date-time } - job_id: { type: ["string", "null"] } + job_id: { type: [string, "null"] } step: { type: string } - phase: { type: ["string", "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く } + phase: { type: [string, "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く } issues: { type: array, items: { type: integer }, description: 空はスプリント全体の工程 } exit_code: { type: integer } seconds: { type: number } - cost_usd: { type: ["number", "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null } + cost_usd: { type: [number, "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null } summary: { type: string } - pull_request: - oneOf: - - $ref: "#/components/schemas/PullRequestLink" - - type: "null" + pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] } has_details: { type: boolean, description: 工程の中身の本文を持つか } RequiredStep: type: object @@ -869,17 +735,14 @@ components: oneOf: - type: object required: [key, exit_code, seconds] - properties: - key: { type: string } - exit_code: { type: integer } - seconds: { type: number } - - type: "null" + properties: { key: { type: string }, exit_code: { type: integer }, seconds: { type: number } } + - { type: "null" } GateView: type: object required: [state, at] properties: state: { type: string, enum: [not_reached, waiting, approved, rejected] } - at: { type: ["string", "null"], format: date-time } + at: { type: [string, "null"], format: date-time } SprintRunDetail: type: object required: @@ -905,17 +768,20 @@ components: sprint_run_id: { type: string } repository_id: { type: integer } issues: { type: array, items: { type: integer } } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } created_at: { type: string, format: date-time } repo: { type: string } ndf_version: { type: string } execution_mode: { type: string, enum: [local, byok, managed] } job_count: { type: integer } - current_phase: { type: ["string", "null"] } + current_phase: { type: [string, "null"] } phases: type: array - description: 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ + description: >- + 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ items: type: object required: [name, step_keys, required] @@ -930,7 +796,7 @@ components: type: object required: [issue, step_keys, pull_requests] properties: - issue: { type: ["integer", "null"] } + issue: { type: [integer, "null"] } step_keys: { type: array, items: { type: string } } pull_requests: type: array @@ -949,7 +815,7 @@ components: required: [name, conclusion] properties: name: { type: string } - conclusion: { type: ["string", "null"], description: null は実行中 } + conclusion: { type: [string, "null"], description: null は実行中 } steps: { type: array, items: { $ref: "#/components/schemas/StepRow" } } gates: type: object @@ -962,44 +828,37 @@ components: required: [state, at] properties: state: { type: string, enum: [none, waiting, answered] } - at: { type: ["string", "null"], format: date-time } + at: { type: [string, "null"], format: date-time } delegated_decisions: { type: integer, description: AI が代わりに判断した問い合わせの数 } usage: type: object required: [input_tokens, output_tokens, cost_usd] - properties: - input_tokens: { type: integer } - output_tokens: { type: integer } - cost_usd: { type: number } + properties: { input_tokens: { type: integer }, output_tokens: { type: integer }, cost_usd: { type: number } } omitted_steps: { type: integer, description: 上限で省いた古い工程の数 } StepDetail: allOf: - - $ref: "#/components/schemas/StepRow" + - { $ref: "#/components/schemas/StepRow" } - type: object required: [files, files_omitted, commit_range, decisions, output, diff_url] properties: files: - type: ["array", "null"] + type: [array, "null"] description: null は記録なし、空の配列は触らなかった items: type: object required: [path, added, deleted, url] properties: path: { type: string } - added: { type: ["integer", "null"], description: null はバイナリ } - deleted: { type: ["integer", "null"], description: null はバイナリ } - url: { type: ["string", "null"], format: uri, description: コミットの範囲が無ければ null } - files_omitted: { type: ["integer", "null"] } + added: { type: [integer, "null"], description: null はバイナリ } + deleted: { type: [integer, "null"], description: null はバイナリ } + url: { type: [string, "null"], format: uri, description: コミットの範囲が無ければ null } + files_omitted: { type: [integer, "null"] } commit_range: oneOf: - - type: object - required: [from, to] - properties: - from: { type: string } - to: { type: string } - - type: "null" + - { type: object, required: [from, to], properties: { from: { type: string }, to: { type: string } } } + - { type: "null" } decisions: - type: ["array", "null"] + type: [array, "null"] items: type: object required: [question, answer, by] @@ -1015,8 +874,8 @@ components: kind: { type: string, enum: [report, log] } text: { type: string } truncated: { type: boolean } - - type: "null" - diff_url: { type: ["string", "null"], format: uri } + - { type: "null" } + diff_url: { type: [string, "null"], format: uri } Backlog: type: object required: [repository_id, repo, ranking, refinements] @@ -1039,7 +898,7 @@ components: rank: { type: integer, minimum: 1 } number: { type: integer } title: { type: string } - - type: "null" + - { type: "null" } refinements: type: array description: 棚卸しの工程(step が refine)の記録を新しい順に @@ -1052,61 +911,47 @@ components: at: { type: string, format: date-time } exit_code: { type: integer } summary: { type: string } - pull_request: - oneOf: - - $ref: "#/components/schemas/PullRequestLink" - - type: "null" + pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] } CancelResult: type: object required: [state] - properties: - state: { type: string, enum: [cancelled, running] } + properties: { state: { type: string, enum: [cancelled, running] } } GitHubToken: type: object required: [token, expires_at] - properties: - token: { type: string } - expires_at: { type: string, format: date-time } - StateRequest: - type: object - required: [op] - properties: - op: { type: string, enum: [put, get] } + properties: { token: { type: string }, expires_at: { type: string, format: date-time } } + StateRequest: { type: object, required: [op], properties: { op: { type: string, enum: [put, get] } } } ApprovalAnswer: type: object required: [kind] description: >- - kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で - gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと - body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。 + kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。 properties: kind: { type: string, enum: [gate1, gate2, decision] } decision: { type: string, enum: [approved, rejected] } - body: { type: ["string", "null"], description: 承認の理由か判断の本文 } + body: { type: [string, "null"], description: 承認の理由か判断の本文 } gate: { type: string, description: スプリントの状態に書く関門の名前(省略時は kind から「関門 1」/「関門 2」) } SignedUrl: type: object required: [url, expires_at] - properties: - url: { type: string, format: uri } - expires_at: { type: string, format: date-time } + properties: { url: { type: string, format: uri }, expires_at: { type: string, format: date-time } } OrganizerAnswer: type: object required: [request_id, result, version, release, reason, at] properties: request_id: { type: string } result: { type: string, enum: [released, rejected] } - version: { type: ["string", "null"] } - release: { type: ["string", "null"], description: 本番の Pull Request の URL } - reason: { type: ["string", "null"] } + version: { type: [string, "null"] } + release: { type: [string, "null"], description: 本番の Pull Request の URL } + reason: { type: [string, "null"] } at: { type: string, format: date-time } OrganizerRange: type: object required: [seq, sha, version, prs, lanes] properties: seq: { type: integer } - sha: { type: ["string", "null"] } - version: { type: ["string", "null"] } + sha: { type: [string, "null"] } + version: { type: [string, "null"] } prs: { type: array, items: { type: integer } } lanes: { type: array, items: { type: string } } OrganizerAnswers: @@ -1125,9 +970,7 @@ components: items: type: object required: [id, prs] - properties: - id: { type: string } - prs: { type: array, items: { type: integer } } + properties: { id: { type: string }, prs: { type: array, items: { type: integer } } } issues: { type: array, items: { type: integer } } OrganizerBatch: type: object @@ -1138,3 +981,62 @@ components: lanes: { type: array, items: { $ref: "#/components/schemas/OrganizerBatchLane" } } presentations: { type: array, items: { type: object } } choices: { type: array, items: { type: string, enum: [continue, reject] } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + IssueCandidatesItem: + type: object + properties: + repository_id: { type: integer, minimum: 1 } + exclude: { type: array, items: { type: integer, minimum: 1 } } + required: [repository_id] + StartSprintRunItem: + type: object + required: [idempotency_key, repository_id, issues] + properties: + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + repository_id: { type: integer, minimum: 1 } + issues: { type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: { type: integer, minimum: 1 } } + RepositoryUpdateItem: + type: object + required: [repository_id, max_concurrent_runs] + additionalProperties: false + properties: + repository_id: { type: integer, minimum: 1 } + max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 } + SprintRunRef: + type: object + required: [sprint_run_id] + additionalProperties: false + properties: { sprint_run_id: { type: string, minLength: 1 } } + Removed: { type: object, required: [removed], properties: { removed: { const: true } } } + SprintRunRow: + type: object + required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at] + properties: + sprint_run_id: { type: string } + repository_id: { type: integer } + issues: { type: array, items: { type: integer } } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } + created_at: { type: string, format: date-time } + repo: { type: string, description: owner/name } + current_phase: { type: [string, "null"], description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す } + current_step: { type: [string, "null"], description: 最後の step.recorded のステップ } + last_event_at: { type: [string, "null"], format: date-time, description: 最後の事象の時刻 } diff --git a/factory/contracts/package.json b/factory/contracts/package.json index 99b1d6f47..83b3e6283 100644 --- a/factory/contracts/package.json +++ b/factory/contracts/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./phases": "./src/phases.ts", - "./views": "./src/views.ts" + "./views": "./src/views.ts", + "./operations": "./src/operations.ts" }, "scripts": { "generate": "node scripts/generate.ts", @@ -15,7 +16,9 @@ "check": "npm run typecheck && npm run test" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^24.19.2", + "js-yaml": "^4.3.2", "json-schema-to-typescript": "^16.0.0", "typescript": "^7.0.2", "vitest": "^5.0.5" diff --git a/factory/contracts/scripts/generate.ts b/factory/contracts/scripts/generate.ts index 075581fc7..d617f0b45 100644 --- a/factory/contracts/scripts/generate.ts +++ b/factory/contracts/scripts/generate.ts @@ -1,9 +1,11 @@ -// JSON Schema(schemas/)から TypeScript の型(src/generated/)を作る。定義が正で、型は生成物(決定 2) -// node scripts/generate.ts 生成した型を書く -// node scripts/generate.ts --check 書かずに、コミットした型と食い違えば 1 で終わる +// JSON Schema(schemas/)から TypeScript の型(src/generated/)を、契約(openapi/)から操作の表を作る。 +// 定義が正で、型と表は生成物(決定 2・#2384 の決定 5) +// node scripts/generate.ts 生成した型と表を書く +// node scripts/generate.ts --check 書かずに、コミットした型か表と食い違えば 1 で終わる import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { compile, type JSONSchema } from "json-schema-to-typescript"; +import { generateOperations, operationsPath } from "./operations.ts"; const root = fileURLToPath(new URL("..", import.meta.url)); @@ -37,6 +39,8 @@ export async function staleSchemas(): Promise { const committed = await readFile(generatedPath(name), "utf8").catch(() => null); if (committed !== (await generateOne(name))) stale.push(name); } + const committed = await readFile(operationsPath, "utf8").catch(() => null); + if (committed !== (await generateOperations())) stale.push("operations"); return stale; } @@ -50,6 +54,7 @@ async function main(): Promise { return; } for (const name of SCHEMAS) await writeFile(generatedPath(name), await generateOne(name)); + await writeFile(operationsPath, await generateOperations()); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main(); diff --git a/factory/contracts/scripts/operations.ts b/factory/contracts/scripts/operations.ts new file mode 100644 index 000000000..3ab9345bf --- /dev/null +++ b/factory/contracts/scripts/operations.ts @@ -0,0 +1,123 @@ +// 契約(openapi/*.yaml)から操作の表(src/generated/operations.ts)を作る(#2384 の決定 5)。 +// 利用者向けの操作(x-ndf-access を持つ操作)だけを取り、画面・API・MCP・検査が同じ表を読む +import { readdir, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +export const openapiDir = `${root}openapi`; +export const operationsPath = `${root}src/generated/operations.ts`; + +const METHODS = ["get", "post", "put", "patch", "delete"] as const; +const ACCESS = new Set(["read", "write", "human"]); + +type Json = null | boolean | number | string | Json[] | { [k: string]: Json }; +type Obj = { [k: string]: Json }; + +export interface GeneratedParameter { + name: string; + required: boolean; + array: boolean; + description: string | null; + schema: Json; +} + +export interface GeneratedOperation { + operationId: string; + method: string; + path: string; + kind: string; + access: string; + keys: string[]; + idField: string | null; + summary: string; + description: string; + parameters: GeneratedParameter[]; + itemSchema: Json | null; + source: string; +} + +const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); + +// 同じファイルの中の $ref(#/components/...)をほどく。ファイルの外の参照は利用者向けの操作に無い +function deref(doc: Obj, value: Json, seen: string[] = []): Json { + if (Array.isArray(value)) return value.map((v) => deref(doc, v, seen)); + if (!isObj(value)) return value; + const ref = value.$ref; + if (typeof ref === "string") { + if (!ref.startsWith("#/")) throw new Error(`ファイルの外の参照は操作の表に入れられません: ${ref}`); + if (seen.includes(ref)) throw new Error(`参照が巡っています: ${ref}`); + let node: Json = doc; + for (const part of ref.slice(2).split("/")) node = isObj(node) ? (node[part] ?? null) : null; + if (node === null) throw new Error(`参照が見つかりません: ${ref}`); + return deref(doc, node, [...seen, ref]); + } + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deref(doc, v, seen)])); +} + +// 種類は経路の形とメソッドから決める(契約の表の「操作の種類と形」)。POST は、作った識別子を返すか、 +// 同じ経路に別のメソッドがあれば作成、それ以外(経路の末尾が動詞)は操作 +function kindOf(method: string, path: string, op: Obj, pathItem: Obj): string { + if (method === "get") return Array.isArray(op["x-ndf-keys"]) ? "select" : "list"; + if (method === "delete") return "delete"; + if (method === "put" || method === "patch") return "update"; + const others = METHODS.filter((m) => m !== "post" && isObj(pathItem[m])); + return typeof op["x-ndf-id"] === "string" || others.length > 0 ? "create" : "action"; +} + +export async function readOperations(dir = openapiDir): Promise { + const files = (await readdir(dir)).filter((f) => f.endsWith(".yaml") && !f.startsWith(".")).sort(); + const out: GeneratedOperation[] = []; + for (const file of files) { + const doc = yaml.load(await readFile(`${dir}/${file}`, "utf8")) as Obj; + const paths = isObj(doc.paths) ? doc.paths : {}; + for (const [path, item] of Object.entries(paths)) { + if (!isObj(item)) continue; + for (const method of METHODS) { + const op = item[method]; + if (!isObj(op) || op["x-ndf-access"] === undefined) continue; + const access = String(op["x-ndf-access"]); + if (!ACCESS.has(access)) throw new Error(`${op.operationId}: x-ndf-access は read・write・human のどれかです`); + const params = (Array.isArray(op.parameters) ? op.parameters : []).map((p) => deref(doc, p)).filter(isObj); + const body = isObj(op.requestBody) ? deref(doc, op.requestBody) : null; + const bodySchema = isObj(body) && isObj(body.content) && isObj(body.content["application/json"]) ? body.content["application/json"].schema : null; + out.push({ + operationId: String(op.operationId), + method: method.toUpperCase(), + path, + kind: kindOf(method, path, op, item), + access, + keys: Array.isArray(op["x-ndf-keys"]) ? op["x-ndf-keys"].map(String) : [], + idField: typeof op["x-ndf-id"] === "string" ? op["x-ndf-id"] : null, + summary: String(op.summary ?? ""), + description: String(op.description ?? ""), + parameters: params + .filter((p) => p.in === "query") + .map((p) => ({ + name: String(p.name), + required: p.required === true, + array: isObj(p.schema) && p.schema.type === "array", + description: typeof p.description === "string" ? p.description : null, + schema: p.schema ?? null, + })), + itemSchema: isObj(bodySchema) ? (bodySchema.items ?? null) : null, + source: file, + }); + } + } + } + return out.sort((a, b) => a.operationId.localeCompare(b.operationId)); +} + +const BANNER = "// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す"; + +export async function generateOperations(dir = openapiDir): Promise { + const ops = await readOperations(dir); + return [ + BANNER, + 'import type { Operation } from "../operations.ts";', + "", + `export const OPERATIONS = ${JSON.stringify(ops, null, 2)} as const satisfies readonly Operation[];`, + "", + ].join("\n"); +} diff --git a/factory/contracts/src/generated/operations.ts b/factory/contracts/src/generated/operations.ts new file mode 100644 index 000000000..06b5b4cbe --- /dev/null +++ b/factory/contracts/src/generated/operations.ts @@ -0,0 +1,1227 @@ +// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す +import type { Operation } from "../operations.ts"; + +export const OPERATIONS = [ + { + "operationId": "addSeat", + "method": "POST", + "path": "/accounts/{accountId}/seats", + "kind": "create", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席を足す", + "description": "チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "user_id", + "seat_type" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "answerLoopPending", + "method": "POST", + "path": "/accounts/{accountId}/loops/answers", + "kind": "action", + "access": "human", + "keys": [ + "loop_id", + "issue" + ], + "idField": null, + "summary": "人の判断待ちの課題に答える", + "description": "答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id", + "issue", + "decision" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + }, + "issue": { + "type": "integer", + "minimum": 1 + }, + "decision": { + "type": "string", + "enum": [ + "approved", + "rejected" + ] + }, + "pr": { + "type": "integer", + "minimum": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "cancelLoop", + "method": "POST", + "path": "/accounts/{accountId}/loops/cancel", + "kind": "action", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループを止める", + "description": "動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "cancelSprintRun", + "method": "POST", + "path": "/accounts/{accountId}/sprint-runs/cancel", + "kind": "action", + "access": "write", + "keys": [ + "sprint_run_id" + ], + "idField": null, + "summary": "スプリントの実行を取り消す", + "description": "動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "sprint_run_id" + ], + "additionalProperties": false, + "properties": { + "sprint_run_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "changeSeat", + "method": "PATCH", + "path": "/accounts/{accountId}/seats", + "kind": "update", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席の種類を変える", + "description": "チームの持ち主だけが変えられる。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "user_id", + "seat_type" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "createDelegation", + "method": "POST", + "path": "/delegations", + "kind": "create", + "access": "human", + "keys": [ + "idempotency_key" + ], + "idField": "delegation.credential_id", + "summary": "委任の資格情報を発行する", + "description": "値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "client_name", + "scope", + "expires_in_seconds", + "idempotency_key" + ], + "additionalProperties": false, + "properties": { + "client_name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "人が付けるクライアントの名前。操作の記録には写さない" + }, + "scope": { + "type": "string", + "description": "read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない", + "enum": [ + "read", + "read_write" + ] + }, + "expires_in_seconds": { + "type": "integer", + "minimum": 300, + "maximum": 3600 + }, + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵" + } + } + }, + "source": "delegations.yaml" + }, + { + "operationId": "createTeam", + "method": "POST", + "path": "/teams", + "kind": "create", + "access": "human", + "keys": [ + "idempotency_key" + ], + "idField": "account_id", + "summary": "チームのアカウントを作る", + "description": "作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "idempotency_key", + "name", + "seat_type" + ], + "properties": { + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "deleteModelCredential", + "method": "DELETE", + "path": "/accounts/{accountId}/model-credential", + "kind": "delete", + "access": "human", + "keys": [], + "idField": null, + "summary": "持ち込んだキーを消す", + "description": "復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getAccount", + "method": "GET", + "path": "/accounts", + "kind": "select", + "access": "read", + "keys": [ + "account_id" + ], + "idField": null, + "summary": "アカウントの料金プラン・実行の形態・使える量・席", + "description": "個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。", + "parameters": [ + { + "name": "account_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "getAuthorizeUrl", + "method": "GET", + "path": "/accounts/{accountId}/github/authorize-url", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "GitHub App の利用者の認可の URL", + "description": "state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getBacklog", + "method": "GET", + "path": "/accounts/{accountId}/backlogs", + "kind": "select", + "access": "read", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "バックログ", + "description": "リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getInstallUrl", + "method": "GET", + "path": "/accounts/{accountId}/github/install-url", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "GitHub App の導入の画面の URL", + "description": "GitHub App の導入の URL。要素 1 個の配列で返す。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getLoop", + "method": "GET", + "path": "/accounts/{accountId}/loops/detail", + "kind": "select", + "access": "read", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループの詳細", + "description": "締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。", + "parameters": [ + { + "name": "loop_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "loops.yaml" + }, + { + "operationId": "getMe", + "method": "GET", + "path": "/me", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "自分の利用者と、席を持つアカウントの一覧", + "description": "初めての要求では、Free・local の個人のアカウントを 1 つ作る。", + "parameters": [], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "getModelCredential", + "method": "GET", + "path": "/accounts/{accountId}/model-credential", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "持ち込んだキーの有無", + "description": "登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getSprintRunDetail", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs/detail", + "kind": "select", + "access": "read", + "keys": [ + "sprint_run_id" + ], + "idField": null, + "summary": "スプリントの詳細", + "description": "フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "sprint_run_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getStepDetail", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs/steps", + "kind": "select", + "access": "read", + "keys": [ + "step_key" + ], + "idField": null, + "summary": "工程の中身", + "description": "1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "sprint_run_id", + "required": true, + "array": false, + "description": "工程を持つ実行。他人の実行なら要求全体が 403", + "schema": { + "type": "string" + } + }, + { + "name": "step_key", + "required": true, + "array": true, + "description": "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "listDelegations", + "method": "GET", + "path": "/delegations", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "自分が発行した委任の資格情報の一覧", + "description": "新しい順に 100 件まで。値と秘密のハッシュは返さない。", + "parameters": [], + "itemSchema": null, + "source": "delegations.yaml" + }, + { + "operationId": "listLoops", + "method": "GET", + "path": "/accounts/{accountId}/loops", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "リポジトリの開発ループの一覧", + "description": "新しい順に 50 件。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "itemSchema": null, + "source": "loops.yaml" + }, + { + "operationId": "listRepositories", + "method": "GET", + "path": "/accounts/{accountId}/repositories", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "登録したリポジトリの一覧", + "description": "リポジトリごとの終わりの状態でないスプリントの実行の数を添える。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "listRepositoryApprovalRequests", + "method": "GET", + "path": "/accounts/{accountId}/approval-requests", + "kind": "select", + "access": "read", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの承認と問いの一覧", + "description": "同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "organizer.yaml" + }, + { + "operationId": "listRepositoryReleases", + "method": "GET", + "path": "/accounts/{accountId}/releases", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "まとめた配布の履歴", + "description": "新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "limit", + "required": false, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "itemSchema": null, + "source": "organizer.yaml" + }, + { + "operationId": "listSprintRuns", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "スプリントの実行の一覧", + "description": "新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。", + "parameters": [ + { + "name": "repository_id", + "required": false, + "array": false, + "description": "省くとアカウントの新しい順の 50 件", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "postIssueCandidates", + "method": "POST", + "path": "/accounts/{accountId}/issue-candidates", + "kind": "action", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "AI が選んだ課題の候補", + "description": "棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。", + "parameters": [], + "itemSchema": { + "type": "object", + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "exclude": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } + } + }, + "required": [ + "repository_id" + ] + }, + "source": "runs.yaml" + }, + { + "operationId": "postRepositoryCandidates", + "method": "POST", + "path": "/accounts/{accountId}/github/candidates", + "kind": "action", + "access": "write", + "keys": [], + "idField": null, + "summary": "認可の戻りから登録の候補を返す", + "description": "導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "state": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "putModelCredential", + "method": "PUT", + "path": "/accounts/{accountId}/model-credential", + "kind": "update", + "access": "human", + "keys": [], + "idField": null, + "summary": "キーを保存・差し替える", + "description": "Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。", + "parameters": [], + "itemSchema": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind", + "api_key" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "anthropic_api_key" + }, + "api_key": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + }, + { + "type": "object", + "required": [ + "kind", + "role_arn", + "region" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "aws_role" + }, + "role_arn": { + "type": "string", + "maxLength": 2048, + "pattern": "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$" + }, + "region": { + "type": "string", + "maxLength": 32, + "pattern": "^[a-z]{2}(-[a-z]+)+-\\d$" + } + } + } + ] + }, + "source": "runs.yaml" + }, + { + "operationId": "registerRepository", + "method": "POST", + "path": "/accounts/{accountId}/repositories", + "kind": "create", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリを登録する", + "description": "候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "proof", + "repository_id" + ], + "properties": { + "proof": { + "type": "string" + }, + "repository_id": { + "type": "integer", + "minimum": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "removeRepository", + "method": "DELETE", + "path": "/accounts/{accountId}/repositories", + "kind": "delete", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの登録を外す", + "description": "終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "removeSeat", + "method": "DELETE", + "path": "/accounts/{accountId}/seats", + "kind": "delete", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席を外す", + "description": "チームの持ち主だけが外せる。持ち主自身の席は外せない。", + "parameters": [ + { + "name": "user_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "resolveStalledRelease", + "method": "POST", + "path": "/accounts/{accountId}/releases/resolve", + "kind": "action", + "access": "human", + "keys": [ + "repository_id", + "seq" + ], + "idField": null, + "summary": "止まった配布を解く", + "description": "配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "seq", + "choice" + ], + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "choice": { + "type": "string", + "enum": [ + "continue", + "reject" + ] + }, + "body": { + "type": "string", + "maxLength": 4000 + } + } + }, + "source": "organizer.yaml" + }, + { + "operationId": "revokeDelegation", + "method": "DELETE", + "path": "/delegations", + "kind": "delete", + "access": "human", + "keys": [ + "credential_id" + ], + "idField": null, + "summary": "委任の資格情報を取り消す", + "description": "取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。", + "parameters": [ + { + "name": "credential_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "delegations.yaml" + }, + { + "operationId": "setExecutionMode", + "method": "PATCH", + "path": "/accounts", + "kind": "update", + "access": "write", + "keys": [ + "account_id" + ], + "idField": null, + "summary": "実行の形態を変える", + "description": "アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "account_id", + "execution_mode" + ], + "properties": { + "account_id": { + "type": "string", + "minLength": 1 + }, + "execution_mode": { + "type": "string", + "description": "local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock)", + "enum": [ + "local", + "byok", + "managed" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "startLoop", + "method": "POST", + "path": "/accounts/{accountId}/loops", + "kind": "create", + "access": "write", + "keys": [ + "idempotency_key" + ], + "idField": "loop_id", + "summary": "開発ループを始める", + "description": "マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "idempotency_key", + "name", + "milestones" + ], + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "name": { + "type": "string", + "pattern": "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" + }, + "milestones": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[^,]+$" + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "laps": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "usd": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "hours": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + } + } + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "startNextLap", + "method": "POST", + "path": "/accounts/{accountId}/loops/next-lap", + "kind": "action", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "次の周回を始める(形態 local)", + "description": "次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "startSprintRun", + "method": "POST", + "path": "/accounts/{accountId}/sprint-runs", + "kind": "create", + "access": "write", + "keys": [ + "idempotency_key" + ], + "idField": "sprint_run_id", + "summary": "スプリントの実行を始める", + "description": "確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "idempotency_key", + "repository_id", + "issues" + ], + "properties": { + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "issues": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "updateLoopBudget", + "method": "PATCH", + "path": "/accounts/{accountId}/loops", + "kind": "update", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループの予算を変える", + "description": "終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id", + "budget" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "laps": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "usd": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "hours": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + } + } + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "updateRepository", + "method": "PATCH", + "path": "/accounts/{accountId}/repositories", + "kind": "update", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの同時の実行の数を宣言する", + "description": "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "max_concurrent_runs" + ], + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "max_concurrent_runs": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + } + }, + "source": "runs.yaml" + } +] as const satisfies readonly Operation[]; diff --git a/factory/contracts/src/operations.ts b/factory/contracts/src/operations.ts new file mode 100644 index 000000000..8cc39d71b --- /dev/null +++ b/factory/contracts/src/operations.ts @@ -0,0 +1,83 @@ +// 操作の表(#2384 の決定 5)。契約から生成した利用者向けの操作の一覧を、画面・API・MCP・検査が同じく読む +import { OPERATIONS } from "./generated/operations.ts"; + +export { OPERATIONS }; + +// 操作の扱い。read(読み取り)・write(書き込み)・human(人だけが確定できる。委任の資格情報からは断る) +export type OperationAccess = "read" | "write" | "human"; +// 操作の種類。list は条件で集めた値の配列を返し、ほかは要素の結果を入力と同じ順に返す +export type OperationKind = "list" | "select" | "create" | "update" | "delete" | "action"; +export type OperationMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface OperationParameter { + readonly name: string; + readonly required: boolean; + // 鍵の query は繰り返す(?a=1&a=2) + readonly array: boolean; + readonly description: string | null; + readonly schema: unknown; +} + +export interface Operation { + readonly operationId: string; + readonly method: OperationMethod; + // 経路の変数は {accountId} だけ + readonly path: string; + readonly kind: OperationKind; + readonly access: OperationAccess; + // 要素の鍵の項目の名前。鍵を持たない操作は空で、要素は必ず 1 個 + readonly keys: readonly string[]; + // 作成の成功の値の中の、作った識別子の項目(`.` で入れ子を指す) + readonly idField: string | null; + readonly summary: string; + readonly description: string; + readonly parameters: readonly OperationParameter[]; + // 作成・更新・操作の本文の要素の定義(参照をほどいた JSON Schema) + readonly itemSchema: unknown; + readonly source: string; +} + +export type OperationId = (typeof OPERATIONS)[number]["operationId"]; + +// 1 回の要求で受け渡す要素の上限(要求の前提 3) +export const MAX_ITEMS = 100; + +// 操作の記録の target_ids へ写してよい鍵の名前(契約の表の「操作の記録へ写す鍵」。決定 12) +export const AUDIT_KEYS = [ + "account_id", + "user_id", + "repository_id", + "sprint_run_id", + "step_key", + "loop_id", + "issue", + "seq", + "credential_id", + "idempotency_key", +] as const; + +const byId = new Map(OPERATIONS.map((op) => [op.operationId, op as Operation])); + +export function operation(operationId: OperationId): Operation { + return byId.get(operationId)!; +} + +export function findOperation(operationId: string): Operation | null { + return byId.get(operationId) ?? null; +} + +// 経路の {accountId} を埋め、query を足した URL の経路を返す。配列の値は同じ名前で繰り返す +export function operationUrl(op: Operation, accountId: string | null, query: Record = {}): string { + let path = op.path; + if (path.includes("{accountId}")) { + if (accountId === null) throw new Error(`${op.operationId} はアカウントの ID が要ります`); + path = path.replace("{accountId}", encodeURIComponent(accountId)); + } + const params = new URLSearchParams(); + for (const [name, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + for (const v of Array.isArray(value) ? value : [value]) params.append(name, String(v)); + } + const qs = params.toString(); + return qs ? `${path}?${qs}` : path; +} diff --git a/factory/contracts/tests/operations.test.ts b/factory/contracts/tests/operations.test.ts new file mode 100644 index 000000000..632a63495 --- /dev/null +++ b/factory/contracts/tests/operations.test.ts @@ -0,0 +1,118 @@ +// 機能から起こした契約の形と操作の表(#2384 の受け入れ条件 4・5・16、不変条件 I10) +import { readdir, readFile } from "node:fs/promises"; +import yaml from "js-yaml"; +import { describe, expect, it } from "vitest"; +import { openapiDir, readOperations } from "../scripts/operations.ts"; +import { AUDIT_KEYS, OPERATIONS, operation, operationUrl } from "../src/operations.ts"; + +type Obj = Record; +const METHODS = ["get", "post", "put", "patch", "delete"]; + +async function allOperations(): Promise<{ file: string; path: string; method: string; op: Obj; doc: Obj }[]> { + const out = []; + for (const file of (await readdir(openapiDir)).filter((f) => f.endsWith(".yaml") && !f.startsWith("."))) { + const doc = yaml.load(await readFile(`${openapiDir}/${file}`, "utf8")) as Obj; + for (const [path, item] of Object.entries(doc.paths as Obj)) { + for (const method of METHODS) if (item[method]) out.push({ file, path, method, op: item[method] as Obj, doc }); + } + } + return out; +} + +function resolve(doc: Obj, schema: Obj): Obj { + if (typeof schema.$ref !== "string") return schema; + return schema.$ref + .slice(2) + .split("/") + .reduce((node: Obj, part: string) => node[part], doc); +} + +describe("利用者向けの操作の形(受け入れ条件 4・5)", () => { + it("成功の応答と要求の本文が配列である", async () => { + const userOps = (await allOperations()).filter((o) => o.op["x-ndf-access"] !== undefined); + expect(userOps.length).toBe(OPERATIONS.length); + for (const { op, doc } of userOps) { + const ok = op.responses["200"]; + expect(ok, `${op.operationId} の 200`).toBeDefined(); + expect(resolve(doc, ok.content["application/json"].schema).type, op.operationId).toBe("array"); + for (const status of Object.keys(op.responses)) expect(["200", "400", "401", "403", "503"], `${op.operationId} の ${status}`).toContain(status); + if (op.requestBody) { + const schema = resolve(doc, op.requestBody.content["application/json"].schema); + expect(schema.type, op.operationId).toBe("array"); + expect([schema.minItems, schema.maxItems], op.operationId).toEqual([1, 100]); + } + } + }); + + it("経路の変数はアカウントの ID だけで、単一のレコードを経路で指す取得が無い", async () => { + for (const { path, op } of await allOperations()) { + if (op["x-ndf-access"] === undefined) continue; + const vars = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]); + expect(vars.filter((v) => v !== "accountId"), `${op.operationId} の ${path}`).toEqual([]); + } + }); + + it("要素を持つ操作の鍵が、query か本文の要素の項目にある", async () => { + for (const op of OPERATIONS) { + if (op.kind === "list") continue; + for (const key of op.keys) { + const inQuery = op.parameters.some((p) => p.name === key && p.array); + const item = op.itemSchema as { properties?: Record } | null; + expect(inQuery || Boolean(item?.properties?.[key]), `${op.operationId} の ${key}`).toBe(true); + } + } + }); + + it("ランナー向けの経路と受け口は操作の表に入らない", () => { + for (const op of OPERATIONS) expect(op.path.startsWith("/run-jobs/") || op.path.startsWith("/ingest/"), op.operationId).toBe(false); + }); +}); + +describe("操作の扱い(受け入れ条件 16、決定 10)", () => { + it("人だけが確定できる操作は 10 で、ループの判断待ちへの答えを含む", () => { + const human = OPERATIONS.filter((op) => op.access === "human").map((op) => op.operationId); + expect(human.sort()).toEqual( + [ + "addSeat", + "answerLoopPending", + "changeSeat", + "createDelegation", + "createTeam", + "deleteModelCredential", + "putModelCredential", + "removeSeat", + "resolveStalledRelease", + "revokeDelegation", + ].sort(), + ); + expect(operation("answerLoopPending").access).toBe("human"); + }); + + it("読み取りの操作は GET だけで、GET の操作はすべて読み取り", () => { + for (const op of OPERATIONS) expect(op.method === "GET", op.operationId).toBe(op.access === "read"); + }); +}); + +describe("操作の記録へ写す鍵(決定 12)", () => { + it("すべての操作の鍵と作った識別子の項目が、許可した鍵の一覧にある", () => { + const allowed: readonly string[] = AUDIT_KEYS; + for (const op of OPERATIONS) { + for (const key of op.keys) expect(allowed, `${op.operationId} の ${key}`).toContain(key); + if (op.idField) expect(allowed, `${op.operationId} の ${op.idField}`).toContain(op.idField.split(".").at(-1)); + } + }); +}); + +describe("操作の表の生成", () => { + it("契約から読んだ操作と表が一致する", async () => { + expect((await readOperations()).map((o) => o.operationId)).toEqual(OPERATIONS.map((o) => o.operationId)); + }); + + it("経路を表から組み、鍵の query を繰り返す", () => { + expect(operationUrl(operation("getSprintRunDetail"), "a/1", { sprint_run_id: ["s1", "s2"] })).toBe( + "/accounts/a%2F1/sprint-runs/detail?sprint_run_id=s1&sprint_run_id=s2", + ); + expect(operationUrl(operation("getMe"), null)).toBe("/me"); + expect(() => operationUrl(operation("listLoops"), null)).toThrow(); + }); +}); diff --git a/factory/package-lock.json b/factory/package-lock.json index 9f99ff596..43c648eb2 100644 --- a/factory/package-lock.json +++ b/factory/package-lock.json @@ -29,6 +29,7 @@ "@aws-sdk/lib-dynamodb": "^3.1160.0", "@aws-sdk/s3-request-presigner": "^3.1161.0", "@aws-sdk/util-dynamodb": "^3.996.9", + "@modelcontextprotocol/server": "^2.4.0", "@ndf-factory/contracts": "*", "aws-jwt-verify": "^5.2.1", "hono": "^4.13.14", @@ -66,7 +67,9 @@ "ajv-formats": "^3.0.1" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^24.19.2", + "js-yaml": "^4.3.2", "json-schema-to-typescript": "^16.0.0", "typescript": "^7.0.2", "vitest": "^5.0.5" @@ -99,6 +102,36 @@ } } }, + "contracts/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "contracts/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "contracts/node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -1031,6 +1064,31 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", + "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", + "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.4.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@ndf-factory/api": { "resolved": "api", "link": true @@ -2502,6 +2560,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7416,6 +7481,15 @@ "node": ">=12" } }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "runner": { "name": "@ndf-factory/runner", "dependencies": { diff --git a/factory/web/scripts/check-source.ts b/factory/web/scripts/check-source.ts index ce3c25f3d..2e1e2bd59 100644 --- a/factory/web/scripts/check-source.ts +++ b/factory/web/scripts/check-source.ts @@ -1,4 +1,5 @@ -// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないかを見る +// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないか、 +// API への要求の経路を文字列で直に組んでいないか(要求は操作の表から組む。#2384 の受け入れ条件 1)を見る // 対象は src/**/*.{ts,tsx}。*.test.*・src/dummy/・翻訳のファイル(JSON)は見ない。コメントは見ない // 終了コード: 0 無し / 1 見つけた / 2 構文解析できない import { readFileSync, readdirSync } from "node:fs"; @@ -10,13 +11,15 @@ export interface Finding { file: string; line: number; column: number; - rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import"; + rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import" | "api-path"; text: string; } const LETTER = /\p{L}/u; const NON_ASCII = /[^\x00-\x7f]/; const TEXT_ATTRS = new Set(["aria-label", "title", "placeholder", "alt", "label"]); +// 要求を送る関数の名前(fetch・doFetch・request など) +const REQUEST_CALLEE = /fetch|request/i; interface Node { type: string; @@ -101,6 +104,21 @@ export function checkSource(file: string, code: string): Finding[] { } break; } + case "CallExpression": { + const callee = node.callee as Node; + const name = callee.type === "Identifier" ? (callee.name as string) : callee.type === "MemberExpression" ? ((callee.property as Node).name as string | undefined) : undefined; + if (name && REQUEST_CALLEE.test(name)) { + for (const arg of node.arguments as Node[]) { + // 先頭が / の文字列か、先頭の固定の部分が / のテンプレート + const head = arg.type === "Literal" ? stringOf(arg) : arg.type === "TemplateLiteral" ? ((arg.quasis as Array<{ value: { cooked: string | null } }>)[0]?.value.cooked ?? "") : null; + if (head !== null && head.startsWith("/")) { + report(arg, "api-path", stringOf(arg) ?? head); + reported.add(arg); + } + } + } + break; + } case "ImportDeclaration": case "ImportExpression": case "ExportAllDeclaration": diff --git a/factory/web/src/App.test.tsx b/factory/web/src/App.test.tsx index e6cc432b4..21ae5f77d 100644 --- a/factory/web/src/App.test.tsx +++ b/factory/web/src/App.test.tsx @@ -70,7 +70,10 @@ function show(path: string, routes: Record Response> = {}, a: Auth ); } -const ok = (body: unknown) => () => Response.json(body); +// 一覧は値の配列、ほかの操作は要素の結果の配列で返す(#2384) +const ok = (body: unknown) => () => Response.json([body]); +const done = (value: unknown) => () => Response.json([{ ok: true, value }]); +const failed = (status: number, error: unknown) => () => Response.json([{ ok: false, status, error }]); const where = () => screen.getByTestId("where").textContent; describe("ログインの画面(受け入れ条件 1・2)", () => { @@ -93,7 +96,7 @@ describe("ログインの画面(受け入れ条件 1・2)", () => { it("戻りの後に自分のアカウントの画面へ移り、料金プランが出る", async () => { const stub: Auth = { ...auth, completeLogin: async () => (sessionStorage.setItem("ndf-factory.access_token", "t"), { ok: true }) }; - show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }, stub); + show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }, stub); expect(await screen.findByText("料金プラン: Free")).toBeTruthy(); expect(where()).toBe("/account"); }); @@ -103,7 +106,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t")); it("2 つの上限と実行の形態と、席を持つチームが出る", async () => { - show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }); + show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }); expect(await screen.findByText("同時に動かせるスプリントの実行")).toBeTruthy(); expect(screen.getByText("1")).toBeTruthy(); expect(screen.getByText("掛けない")).toBeTruthy(); @@ -113,7 +116,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { }); it("ログアウトで token が消え、自分のアカウントを開くとログインの画面へ移る", async () => { - show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }); + show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }); await userEvent.click(await screen.findByRole("button", { name: "ログアウト" })); expect(auth.getToken()).toBeNull(); expect(visited[0]).toContain("https://auth.example.test/logout"); @@ -133,7 +136,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t")); it("席を持つ利用者には上限と席の一覧が出て、席の操作と形態の変更は出ない", async () => { - show("/accounts/t-1", { "GET /accounts/t-1": ok(team) }); + show("/accounts/t-1", { "GET /accounts?account_id=t-1": done(team) }); expect(await screen.findByText("125,000,000")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy(); expect(screen.getAllByRole("listitem")).toHaveLength(2); @@ -144,9 +147,8 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = it("持ち主には操作が出て、API の 422 の文を操作の下に出す", async () => { show("/accounts/t-1", { - "GET /accounts/t-1": ok({ ...team, role: "owner" }), - "POST /accounts/t-1/seats": () => - Response.json({ error: { code: "user_not_found", message: "その利用者はまだログインしていません" } }, { status: 422 }), + "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }), + "POST /accounts/t-1/seats": failed(422, { code: "user_not_found", message: "その利用者はまだログインしていません" }), }); await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "22222222-2222-4222-8222-222222222222"); await userEvent.click(screen.getByRole("button", { name: "席を足す" })); @@ -154,7 +156,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = }); it("利用者の ID の形でなければ送らない", async () => { - show("/accounts/t-1", { "GET /accounts/t-1": ok({ ...team, role: "owner" }) }); + show("/accounts/t-1", { "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }) }); await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "octo"); await userEvent.click(screen.getByRole("button", { name: "席を足す" })); expect(await screen.findByText("利用者の ID の形ではありません")).toBeTruthy(); @@ -172,9 +174,9 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("登録が 0 件なら導入の案内と「まだリポジトリがありません」を出す", async () => { show("/accounts/p-1/repositories", { - "GET /accounts/p-1": ok(owner), + "GET /accounts?account_id=p-1": done(owner), "GET /accounts/p-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }), - "GET /accounts/p-1/repositories": ok({ repositories: [] }), + "GET /accounts/p-1/repositories": () => Response.json([]), }); expect(await screen.findByText("まだリポジトリがありません")).toBeTruthy(); expect(screen.getByRole("link", { name: "GitHub App をリポジトリへ入れる" }).getAttribute("href")).toContain("/installations/new"); @@ -183,10 +185,10 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("持ち主でない席の人には登録と外すを出さない", async () => { show("/accounts/t-1/repositories", { - "GET /accounts/t-1": ok(team), + "GET /accounts?account_id=t-1": done(team), "GET /accounts/t-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }), - "GET /accounts/t-1/repositories": ok({ repositories: [{ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }] }), - "GET /accounts/t-1/sprint-runs": ok({ sprint_runs: [{ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }] }), + "GET /accounts/t-1/repositories": ok({ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }), + "GET /accounts/t-1/sprint-runs": ok({ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }), }); expect(await screen.findByText("o/r")).toBeTruthy(); expect(screen.getByText("#3: 失敗(timeout)")).toBeTruthy(); @@ -198,7 +200,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める let saved: unknown = null; show("/accounts/p-1/model-credential", { "GET /accounts/p-1/model-credential": ok({ registered: false, kind: null, updated_at: null, external_id: "ndf-factory:p-1" }), - "PUT /accounts/p-1/model-credential": () => ((saved = true), Response.json({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })), + "PUT /accounts/p-1/model-credential": () => ((saved = true), done({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })()), }); await userEvent.click(await screen.findByRole("button", { name: "保存する" })); expect(screen.getByRole("alert").textContent).toBe("API キーを入力してください"); @@ -211,7 +213,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("AI の候補を理由つきで出し、上限の 409 を本数で知らせる", async () => { show("/accounts/p-1/repositories/1/sprints/new", { - "POST /accounts/p-1/repositories/1/issue-candidates": ok({ + "POST /accounts/p-1/issue-candidates": done({ source: "ranking", milestone: "03 x", note: null, @@ -220,11 +222,11 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める { number: 6, title: "六", selected: false, reason: "後でよい" }, ], }), - "POST /accounts/p-1/sprint-runs": () => - Response.json( - { error: { code: "limits_exceeded", message: "x", details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] } } }, - { status: 409 }, - ), + "POST /accounts/p-1/sprint-runs": failed(409, { + code: "limits_exceeded", + message: "x", + details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] }, + }), }); expect(await screen.findByText("順位が高い")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: "開始する" })); diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx index 71b92d522..0249b33d6 100644 --- a/factory/web/src/App.tsx +++ b/factory/web/src/App.tsx @@ -20,6 +20,7 @@ import { SprintDetailPage } from "./pages/SprintDetailPage.tsx"; import { BacklogPage } from "./pages/BacklogPage.tsx"; import { LoopsPage } from "./pages/LoopsPage.tsx"; import { LoopDetailPage } from "./pages/LoopDetailPage.tsx"; +import { DelegationsPage } from "./pages/DelegationsPage.tsx"; // 地図の available の行の画面。経路は screens.ts だけが持つ const ELEMENTS: Partial> = { @@ -36,6 +37,7 @@ const ELEMENTS: Partial> = { backlog: , loops: , loopDetail: , + delegations: , }; export interface AppProps { diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts new file mode 100644 index 000000000..69d8edf5a --- /dev/null +++ b/factory/web/src/api.test.ts @@ -0,0 +1,134 @@ +// 画面の API クライアントと契約の対応(#2384 の受け入れ条件 1、性能の条件) +import { describe, expect, it } from "vitest"; +import { MAX_ITEMS, OPERATIONS } from "@ndf-factory/contracts/operations"; +import { ApiFailure, createApi, type Api } from "./api.ts"; +import type { Auth } from "./auth.ts"; + +const API_URL = "https://api.example.test"; +const auth = { getToken: () => "t", clearToken: () => {} } as unknown as Auth; + +interface Sent { + method: string; + path: string; + query: URLSearchParams; + body: unknown; +} + +// 送った要求を記録し、要素の数だけ成功の結果を返す偽の API +function recorder() { + const sent: Sent[] = []; + const doFetch: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + sent.push({ method: init?.method ?? "GET", path: url.pathname, query: url.searchParams, body }); + const op = OPERATIONS.find((o) => o.method === (init?.method ?? "GET") && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(url.pathname)); + if (!op || op.kind === "list") return Response.json([{}]); + const n = Array.isArray(body) ? body.length : op.keys.length === 0 ? 1 : url.searchParams.getAll(op.keys[0]!).length; + return Response.json(Array.from({ length: n }, () => ({ ok: true, value: {} }))); + }; + return { sent, api: createApi(API_URL, auth, doFetch) }; +} + +// Api のメソッドをすべて 1 回ずつ呼ぶ。メソッドを足すとここが型で落ちる(呼び忘れを防ぐ) +const CALLS: { [K in keyof Api]: (api: Api) => Promise } = { + me: (a) => a.me(), + account: (a) => a.account("p-1"), + createTeam: (a) => a.createTeam("t", "standard"), + setExecutionMode: (a) => a.setExecutionMode("p-1", "byok"), + addSeat: (a) => a.addSeat("t-1", "u", "standard"), + changeSeat: (a) => a.changeSeat("t-1", "u", "premium"), + removeSeat: (a) => a.removeSeat("t-1", "u"), + installUrl: (a) => a.installUrl("p-1"), + authorizeUrl: (a) => a.authorizeUrl("p-1"), + repositoryCandidates: (a) => a.repositoryCandidates("p-1", "c", "s"), + repositories: (a) => a.repositories("p-1"), + registerRepository: (a) => a.registerRepository("p-1", "proof", 1), + removeRepository: (a) => a.removeRepository("p-1", 1), + modelCredential: (a) => a.modelCredential("p-1"), + saveModelCredential: (a) => a.saveModelCredential("p-1", { kind: "anthropic_api_key", api_key: "k" }), + deleteModelCredential: (a) => a.deleteModelCredential("p-1"), + issueCandidates: (a) => a.issueCandidates("p-1", 1, []), + startSprintRun: (a) => a.startSprintRun("p-1", 1, [3]), + sprintRuns: (a) => a.sprintRuns("p-1"), + sprintRunDetails: (a) => a.sprintRunDetails("p-1", ["s1"]), + stepDetails: (a) => a.stepDetails("p-1", "s1", ["k1"]), + backlogs: (a) => a.backlogs("p-1", [1]), + cancelSprintRun: (a) => a.cancelSprintRun("p-1", "s1"), + updateRepository: (a) => a.updateRepository("p-1", 1, null), + loops: (a) => a.loops("p-1", 1), + startLoop: (a) => a.startLoop("p-1", 1, { name: "l", milestones: ["m"] }), + loopDetails: (a) => a.loopDetails("p-1", ["l1"]), + updateLoopBudget: (a) => a.updateLoopBudget("p-1", "l1", {}), + cancelLoop: (a) => a.cancelLoop("p-1", "l1"), + requestNextLap: (a) => a.requestNextLap("p-1", "l1"), + answerLoop: (a) => a.answerLoop("p-1", "l1", 3, "approved"), + delegations: (a) => a.delegations(), + createDelegation: (a) => a.createDelegation({ client_name: "c", scope: "read", expires_in_seconds: 900 }), + revokeDelegation: (a) => a.revokeDelegation("d1"), +}; + +describe("画面の API クライアントと契約(受け入れ条件 1)", () => { + it("どのメソッドの要求も契約の利用者向けの操作に当たり、本文は配列である", async () => { + const { sent, api } = recorder(); + for (const [name, call] of Object.entries(CALLS)) { + const before = sent.length; + await call(api); + expect(sent.length, name).toBeGreaterThan(before); + } + for (const s of sent) { + const op = OPERATIONS.find((o) => o.method === s.method && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(s.path)); + expect(op, `${s.method} ${s.path}`).toBeDefined(); + if (s.body !== undefined) expect(Array.isArray(s.body), `${s.method} ${s.path}`).toBe(true); + } + }); + + it("新しい識別子を作る作成には冪等の鍵を付け、呼ぶたびに変える", async () => { + const { sent, api } = recorder(); + await api.startSprintRun("p-1", 1, [3]); + await api.startSprintRun("p-1", 1, [3]); + const keys = sent.map((s) => (s.body as { idempotency_key: string }[])[0]!.idempotency_key); + expect(keys[0]).toMatch(/^[0-9A-Za-z-]{1,64}$/); + expect(keys[0]).not.toBe(keys[1]); + }); + + it("結果が分からない失敗(503・通信の失敗)の後の同じ中身の送り直しは同じ鍵を使い、断りの後は変える", async () => { + const keys: string[] = []; + const replies = [503, 0, 200, 409, 200]; + const doFetch: typeof fetch = async (_input, init) => { + keys.push((JSON.parse(String(init?.body)) as { idempotency_key: string }[])[0]!.idempotency_key); + const status = replies.shift()!; + if (status === 0) throw new TypeError("Failed to fetch"); + if (status === 503) return Response.json({ error: { code: "audit_unavailable", message: "" } }, { status }); + if (status === 409) return Response.json([{ ok: false, status, error: { code: "limits_exceeded", message: "" } }]); + return Response.json([{ ok: true, value: {} }]); + }; + const api = createApi(API_URL, auth, doFetch); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(TypeError); + await api.createTeam("t", "standard"); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure); + await api.createTeam("t", "standard"); + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).toBe(keys[0]); + expect(keys[3]).not.toBe(keys[0]); + expect(keys[4]).not.toBe(keys[3]); + }); +}); + +describe("要求の回数(非機能の条件「性能・拡張性」)", () => { + it("詳細 3 件は 1 回の要求で読み、上限を超える数は 100 ずつに分ける", async () => { + const { sent, api } = recorder(); + expect(await api.sprintRunDetails("p-1", ["a", "b", "c"])).toHaveLength(3); + expect(sent).toHaveLength(1); + expect(sent[0]!.query.getAll("sprint_run_id")).toEqual(["a", "b", "c"]); + sent.length = 0; + await api.loopDetails("p-1", Array.from({ length: MAX_ITEMS + 1 }, (_, i) => `l${i}`)); + expect(sent.map((s) => s.query.getAll("loop_id").length)).toEqual([MAX_ITEMS, 1]); + }); + + it("0 件の取得は要求を送らない", async () => { + const { sent, api } = recorder(); + expect(await api.backlogs("p-1", [])).toEqual([]); + expect(sent).toHaveLength(0); + }); +}); diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index bed32de43..f3406809d 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -1,5 +1,6 @@ import type { Auth } from "./auth.ts"; import type { StepDetailBody } from "@ndf-factory/contracts/views"; +import { MAX_ITEMS, operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; // API の応答の形(factory/contracts/openapi/accounts.yaml) export type Plan = "free" | "pro" | "max" | "team"; @@ -230,6 +231,25 @@ export interface LoopDetail { pending: LoopPending[]; } +// エージェントへの委任(factory/contracts/openapi/delegations.yaml。#2384) +export type DelegationScope = "read" | "read_write"; + +export interface Delegation { + credential_id: string; + client_name: string; + scope: DelegationScope; + created_at: string; + expires_at: string; + revoked_at: string | null; + state: "active" | "expired" | "revoked"; +} + +export interface DelegationInput { + client_name: string; + scope: DelegationScope; + expires_in_seconds: number; +} + export interface Api { me(): Promise; account(id: string): Promise; @@ -268,22 +288,46 @@ export interface Api { cancelLoop(id: string, loopId: string): Promise<{ state: LoopState }>; requestNextLap(id: string, loopId: string): Promise<{ state: LoopState }>; answerLoop(id: string, loopId: string, issue: number, decision: LoopDecision, pr?: number): Promise<{ pending: LoopPending[] }>; + // 自分が発行した委任の資格情報(新しい順) + delegations(): Promise; + // 値(credential)は発行の結果で 1 度だけ返る + createDelegation(input: DelegationInput): Promise<{ delegation: Delegation; credential: string | null }>; + revokeDelegation(credentialId: string): Promise; } +// 要素の結果(#2384)。入力と同じ順・同じ数で返る +type ElementResult = { ok: true; value: T } | { ok: false; status: number; error: { code: string; message: string; details?: Record } }; + +function valueOf(r: ElementResult | undefined): T { + if (!r) throw new ApiFailure(503, "error", ""); + if (r.ok) return r.value; + throw new ApiFailure(r.status, r.error.code, r.error.message, r.error.details ?? null); +} + +// 1 回の要求で受け渡す要素の上限ごとに分ける +function chunks(items: T[]): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += MAX_ITEMS) out.push(items.slice(i, i + MAX_ITEMS)); + return out; +} + +const newKey = () => crypto.randomUUID(); + +// 要求は操作の表から組み、経路の文字列を直に書かない(#2384 の受け入れ条件 1)。 // 401 では token を消す。画面は ApiFailure の status を見てログインへ移る export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (...a) => fetch(...a)): Api { - async function request(method: string, path: string, body?: unknown): Promise { + async function request(id: OperationId, accountId: string | null, opts: { query?: Record; items?: unknown[] } = {}): Promise { const token = auth.getToken(); if (!token) throw new ApiFailure(401, "unauthorized", "unauthorized"); - const res = await doFetch(`${apiUrl}${path}`, { - method, + const op = operation(id); + const res = await doFetch(`${apiUrl}${operationUrl(op, accountId, opts.query)}`, { + method: op.method, headers: { Authorization: `Bearer ${token}`, - ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...(opts.items === undefined ? {} : { "Content-Type": "application/json" }), }, - body: body === undefined ? undefined : JSON.stringify(body), + body: opts.items === undefined ? undefined : JSON.stringify(opts.items), }); - if (res.status === 204) return undefined as T; const data = (await res.json().catch(() => null)) as { error?: { code: string; message: string; details?: Record } } | null; if (!res.ok) { if (res.status === 401) auth.clearToken(); @@ -292,54 +336,85 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. return data as T; } - const seg = encodeURIComponent; + // 一覧(条件で集めた値の配列) + const list = (id: OperationId, accountId: string | null, query?: Record) => request(id, accountId, { query }); + // 要素 1 個を送り、その結果の値を返す。失敗の要素は ApiFailure にする + const one = async (id: OperationId, accountId: string | null, item: Record) => + valueOf((await request[]>(id, accountId, { items: [item] }))[0]); + // 作成(新しい識別子を作る操作)。冪等の鍵は送る中身ごとに持ち、結果が分からない失敗(503・通信の失敗)の後に + // 同じ中身を送り直すときは同じ鍵を使う(保存の後に記録の追記が落ちた 503 で、作り直しを 2 重にしない。#2384 の決定 4)。 + // 成功か、断りの結果(503 以外の失敗)が届いたら鍵を捨て、次の送信は新しい作成にする + const pendingKeys = new Map(); + const create = async (id: OperationId, accountId: string | null, item: Record) => { + const slot = JSON.stringify([id, accountId, item]); + const idempotency_key = pendingKeys.get(slot) ?? newKey(); + pendingKeys.set(slot, idempotency_key); + try { + const value = await one(id, accountId, { ...item, idempotency_key }); + pendingKeys.delete(slot); + return value; + } catch (err) { + if (err instanceof ApiFailure && err.status !== 503) pendingKeys.delete(slot); + throw err; + } + }; + // 鍵の query で指す取得と削除。要素の結果を入力と同じ順に返す + const select = async (id: OperationId, accountId: string | null, key: string, values: (string | number)[], query: Record = {}) => { + const out: ElementResult[] = []; + for (const part of chunks(values)) out.push(...(await request[]>(id, accountId, { query: { ...query, [key]: part } }))); + return out; + }; + // 鍵の query で指す要素の値の配列。最初の失敗の要素で投げる + const values = async (id: OperationId, accountId: string | null, key: string, ids: (string | number)[], query?: Record) => + (await select(id, accountId, key, ids, query)).map((r) => valueOf(r)); + return { - me: () => request("GET", "/me"), - account: (id) => request("GET", `/accounts/${seg(id)}`), - createTeam: (name, seat_type) => request("POST", "/teams", { name, seat_type }), - setExecutionMode: (id, execution_mode) => request("PUT", `/accounts/${seg(id)}/execution-mode`, { execution_mode }), - addSeat: (id, user_id, seat_type) => request("POST", `/accounts/${seg(id)}/seats`, { user_id, seat_type }), - changeSeat: (id, userId, seat_type) => request("PATCH", `/accounts/${seg(id)}/seats/${seg(userId)}`, { seat_type }), - removeSeat: (id, userId) => request("DELETE", `/accounts/${seg(id)}/seats/${seg(userId)}`), - installUrl: (id) => request("GET", `/accounts/${seg(id)}/github/install-url`), - authorizeUrl: (id) => request("GET", `/accounts/${seg(id)}/github/authorize-url`), - repositoryCandidates: (id, code, state) => request("POST", `/accounts/${seg(id)}/github/candidates`, { code, state }), - repositories: (id) => request("GET", `/accounts/${seg(id)}/repositories`), - registerRepository: (id, proof, repository_id) => request("POST", `/accounts/${seg(id)}/repositories`, { proof, repository_id }), - removeRepository: (id, repositoryId) => request("DELETE", `/accounts/${seg(id)}/repositories/${repositoryId}`), - modelCredential: (id) => request("GET", `/accounts/${seg(id)}/model-credential`), - saveModelCredential: (id, input) => request("PUT", `/accounts/${seg(id)}/model-credential`, input), - deleteModelCredential: (id) => request("DELETE", `/accounts/${seg(id)}/model-credential`), - issueCandidates: (id, repositoryId, exclude) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/issue-candidates`, { exclude }), - startSprintRun: (id, repository_id, issues) => request("POST", `/accounts/${seg(id)}/sprint-runs`, { repository_id, issues }), - sprintRuns: (id, repositoryId) => - request("GET", `/accounts/${seg(id)}/sprint-runs${repositoryId === undefined ? "" : `?repository_id=${repositoryId}`}`), - // 経路は 1 件ずつ読む - sprintRunDetails: (id, sprintRunIds) => - Promise.all(sprintRunIds.map((sprintRunId) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}`))), - stepDetails: (id, sprintRunId, stepKeys) => - Promise.all(stepKeys.map((key) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/steps/${seg(key)}`))), - backlogs: (id, repositoryIds) => Promise.all(repositoryIds.map((repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/backlog`))), - cancelSprintRun: (id, sprintRunId) => request("POST", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/cancel`), - updateRepository: (id, repositoryId, max_concurrent_runs) => request("PATCH", `/accounts/${seg(id)}/repositories/${repositoryId}`, { max_concurrent_runs }), - loops: (id, repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`), - startLoop: (id, repositoryId, input) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`, input), - // 経路は 1 件ずつ読む。404 の ID は要素を返さない + me: async () => (await list("getMe", null))[0]!, + account: async (id) => (await values("getAccount", null, "account_id", [id]))[0]!, + createTeam: (name, seat_type) => create("createTeam", null, { name, seat_type }), + setExecutionMode: (id, execution_mode) => one("setExecutionMode", null, { account_id: id, execution_mode }), + addSeat: (id, user_id, seat_type) => one("addSeat", id, { user_id, seat_type }), + changeSeat: (id, user_id, seat_type) => one("changeSeat", id, { user_id, seat_type }), + removeSeat: async (id, userId) => { + await values("removeSeat", id, "user_id", [userId]); + }, + installUrl: async (id) => (await list<{ url: string }>("getInstallUrl", id))[0]!, + authorizeUrl: async (id) => (await list<{ url: string }>("getAuthorizeUrl", id))[0]!, + repositoryCandidates: (id, code, state) => one("postRepositoryCandidates", id, { code, state }), + repositories: async (id) => ({ repositories: await list("listRepositories", id) }), + registerRepository: (id, proof, repository_id) => one("registerRepository", id, { proof, repository_id }), + removeRepository: async (id, repositoryId) => { + await values("removeRepository", id, "repository_id", [repositoryId]); + }, + modelCredential: async (id) => (await list("getModelCredential", id))[0]!, + saveModelCredential: (id, input) => one("putModelCredential", id, input), + deleteModelCredential: async (id) => { + valueOf((await request[]>("deleteModelCredential", id))[0]); + }, + issueCandidates: (id, repository_id, exclude) => one("postIssueCandidates", id, { repository_id, exclude }), + startSprintRun: (id, repository_id, issues) => create("startSprintRun", id, { repository_id, issues }), + sprintRuns: async (id, repositoryId) => ({ + sprint_runs: await list("listSprintRuns", id, repositoryId === undefined ? {} : { repository_id: repositoryId }), + }), + // 要素の数によらず 1 回の要求で読む。無い実行は他人の実行と同じ forbidden の要素で、そのまま投げる + sprintRunDetails: (id, sprintRunIds) => values("getSprintRunDetail", id, "sprint_run_id", sprintRunIds), + stepDetails: (id, sprintRunId, stepKeys) => values("getStepDetail", id, "step_key", stepKeys, { sprint_run_id: sprintRunId }), + backlogs: (id, repositoryIds) => values("getBacklog", id, "repository_id", repositoryIds), + cancelSprintRun: (id, sprint_run_id) => one("cancelSprintRun", id, { sprint_run_id }), + updateRepository: (id, repository_id, max_concurrent_runs) => one("updateRepository", id, { repository_id, max_concurrent_runs }), + loops: async (id, repositoryId) => ({ loops: await list("listLoops", id, { repository_id: repositoryId }) }), + startLoop: (id, repository_id, input) => create("startLoop", id, { repository_id, ...input }), + // 要素の数によらず 1 回の要求で読む。無いループ(loop_not_found)の要素は返さない loopDetails: async (id, loopIds) => { - const found = await Promise.all( - loopIds.map((loopId) => - request("GET", `/accounts/${seg(id)}/loops/${seg(loopId)}`).catch((err: unknown) => { - if (err instanceof ApiFailure && err.status === 404) return null; - throw err; - }), - ), - ); - return found.filter((d): d is LoopDetail => d !== null); + const results = await select("getLoop", id, "loop_id", loopIds); + return results.filter((r) => r.ok || r.status !== 404).map((r) => valueOf(r)); }, - updateLoopBudget: (id, loopId, budget) => request("PATCH", `/accounts/${seg(id)}/loops/${seg(loopId)}`, { budget }), - cancelLoop: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/cancel`), - requestNextLap: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/next-lap`), - answerLoop: (id, loopId, issue, decision, pr) => - request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/answers`, { issue, decision, ...(pr === undefined ? {} : { pr }) }), + updateLoopBudget: (id, loop_id, budget) => one("updateLoopBudget", id, { loop_id, budget }), + cancelLoop: (id, loop_id) => one("cancelLoop", id, { loop_id }), + requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }), + answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }), + delegations: () => list("listDelegations", null), + createDelegation: (input) => create("createDelegation", null, { ...input }), + revokeDelegation: async (credentialId) => (await values("revokeDelegation", null, "credential_id", [credentialId]))[0]!, }; } diff --git a/factory/web/src/checks.test.ts b/factory/web/src/checks.test.ts index 2ab3f86f1..869f57a28 100644 --- a/factory/web/src/checks.test.ts +++ b/factory/web/src/checks.test.ts @@ -46,6 +46,15 @@ describe("ダミーの import の検査(受け入れ条件 17)", () => { }); }); +describe("API への要求の経路の検査(#2384 の受け入れ条件 1)", () => { + it("要求の関数へ経路の文字列を直に渡すと落とし、操作の表から組んだ経路は通す", () => { + expect(rules("api.ts", `request("GET", "/me");`)).toEqual(["api-path"]); + expect(rules("api.ts", "doFetch(`/accounts/${id}/sprint-runs`);")).toEqual(["api-path"]); + expect(rules("api.ts", "doFetch(`${apiUrl}${operationUrl(op, id)}`);")).toEqual([]); + expect(rules("pages/A.tsx", `navigate("/accounts/a/sprints");`)).toEqual([]); + }); +}); + describe("今の源", () => { it("検査で何も見つからない", () => { expect(checkTree(fileURLToPath(new URL(".", import.meta.url)))).toEqual([]); diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx index fbe53641a..f96393dc3 100644 --- a/factory/web/src/context.tsx +++ b/factory/web/src/context.tsx @@ -12,6 +12,8 @@ export interface Services { connection: ConnectionSource; // 一覧と詳細を読み直す間隔(ミリ秒)。無ければ既定の 30 秒 pollIntervalMs?: number; + // API の入口。委任の画面が MCP のクライアントへの登録の例に出す + apiUrl?: string; } export const ServicesContext = createContext(null); diff --git a/factory/web/src/dummy/api.ts b/factory/web/src/dummy/api.ts index 1e00b781e..09a56f631 100644 --- a/factory/web/src/dummy/api.ts +++ b/factory/web/src/dummy/api.ts @@ -1,4 +1,4 @@ -import { ApiFailure, type Api } from "../api.ts"; +import { ApiFailure, type Api, type Delegation } from "../api.ts"; import * as data from "./data.ts"; // 場面: normal は決まったデータ、empty は一覧が 0 件、error は読み込みがすべて失敗する @@ -15,6 +15,8 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); return found; }; + // 発行した委任はこの画面の間だけ持つ(値はダミーで、本物の API へは届かない) + const delegations: Delegation[] = scenario === "empty" ? [] : structuredClone(data.delegations); const loopOf = (loopId: string) => { const found = data.loopDetails.find((d) => d.loop.loop_id === loopId); if (!found) throw new ApiFailure(404, "loop_not_found", "loop_not_found"); @@ -78,5 +80,26 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { requestNextLap: () => read(() => ({ state: "running" as const })), answerLoop: (_id, loopId, issue, decision, pr) => read(() => ({ pending: loopOf(loopId).pending.map((p) => (p.number === issue ? { ...p, answer: { decision, pr: pr ?? null } } : p)) })), + delegations: () => read(() => delegations), + createDelegation: async (input) => { + const now = new Date(); + const delegation: Delegation = { + credential_id: `dummy${String(delegations.length + 1).padStart(21, "0")}`, + client_name: input.client_name, + scope: input.scope, + created_at: now.toISOString(), + expires_at: new Date(now.getTime() + input.expires_in_seconds * 1000).toISOString(), + revoked_at: null, + state: "active", + }; + delegations.unshift(delegation); + return read(() => ({ delegation, credential: `ndfd_${delegation.credential_id}_${data.DUMMY_MARKER}` })); + }, + revokeDelegation: async (credentialId) => { + const found = delegations.find((d) => d.credential_id === credentialId); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + if (found.state === "active") Object.assign(found, { state: "revoked", revoked_at: new Date().toISOString() }); + return read(() => found); + }, }; } diff --git a/factory/web/src/dummy/data.ts b/factory/web/src/dummy/data.ts index 2c42cf860..0202d780a 100644 --- a/factory/web/src/dummy/data.ts +++ b/factory/web/src/dummy/data.ts @@ -1,4 +1,4 @@ -import type { AccountDetail, Backlog, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; +import type { AccountDetail, Backlog, Delegation, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; // ダミーデータ。本番の成果物にこの印が出たら check:bundle が落とす。実在の人・秘密を書かない export const DUMMY_MARKER = "ndf-factory-dummy-data"; @@ -281,3 +281,16 @@ export const loopDetails: LoopDetail[] = [ pending: [], }, ]; + +// 委任の資格情報の一覧(値は持たない) +export const delegations: Delegation[] = [ + { + credential_id: "dummy000000000000000000001", + client_name: "見本の Claude Code", + scope: "read", + created_at: "2026-11-04T00:00:00.000Z", + expires_at: "2026-11-04T01:00:00.000Z", + revoked_at: null, + state: "expired", + }, +]; diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json index b07cc7b59..a28c0907c 100644 --- a/factory/web/src/i18n/ja.json +++ b/factory/web/src/i18n/ja.json @@ -29,7 +29,8 @@ "billing": "請求と使用量", "runners": "手元のマシン", "loops": "ループ", - "loopDetail": "ループの詳細" + "loopDetail": "ループの詳細", + "delegations": "エージェントへの委任" }, "connection": { "label": "つながりの状態", @@ -390,5 +391,33 @@ "exitCode": "終了コード", "back": "リポジトリへ戻る", "issueLine": "#{{number}} {{title}}" + }, + "delegations": { + "intro": "手元の Claude Code などのエージェントに、あなたの代わりに Factory を操作させる資格情報を発行します。承認と、キー・席・チーム・委任そのものの操作は、委任からは行えません。", + "clientName": "クライアントの名前", + "needClientName": "名前を入力してください", + "scope": "範囲", + "scopeRead": "読み取りだけ", + "scopeReadWrite": "読み書き", + "expires": "期限", + "minutes": "{{count}} 分", + "hours": "{{count}} 時間", + "issue": "発行する", + "issuedTitle": "発行した資格情報", + "issuedNote": "この値は今だけ表示します。閉じると二度と表示しません。", + "value": "資格情報の値", + "registerExample": "MCP のクライアントへの登録の例", + "close": "閉じる", + "valueUnavailable": "値はもう表示できません。この資格情報を取り消して発行し直してください", + "list": "発行した資格情報", + "empty": "まだ委任していません", + "createdAt": "発行", + "expiresAt": "期限", + "state": "状態", + "stateActive": "有効", + "stateExpired": "期限切れ", + "stateRevoked": "取り消し済み", + "revoke": "取り消す", + "offline": "オフラインの間は発行と取り消しができません" } } diff --git a/factory/web/src/main.tsx b/factory/web/src/main.tsx index 16f3b25c3..37573693f 100644 --- a/factory/web/src/main.tsx +++ b/factory/web/src/main.tsx @@ -20,7 +20,7 @@ async function buildServices(): Promise { } const config = embeddedConfig(); const auth = createAuth(config); - return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs }; + return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs, apiUrl: config.apiUrl }; } const style = document.createElement("style"); diff --git a/factory/web/src/pages/AccountPage.tsx b/factory/web/src/pages/AccountPage.tsx index ec8930929..79fe28586 100644 --- a/factory/web/src/pages/AccountPage.tsx +++ b/factory/web/src/pages/AccountPage.tsx @@ -57,6 +57,9 @@ export function AccountPage() {

{t("account.idp", { idp: me.idp === "google" ? t("account.idpGoogle") : t("account.idpGithub") })}

+

+ {t("screens.delegations")} +

{t("account.plan", { plan: t(`plan.${personal.plan}`) })}

diff --git a/factory/web/src/pages/DelegationsPage.tsx b/factory/web/src/pages/DelegationsPage.tsx new file mode 100644 index 000000000..64e6757a8 --- /dev/null +++ b/factory/web/src/pages/DelegationsPage.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from "react"; +import { Navigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Delegation, DelegationScope } from "../api.ts"; +import { useConnection, useFailureHandler, useServices } from "../context.tsx"; +import { useFormat } from "../i18n/index.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Button } from "../ui/Button.tsx"; +import { DataList } from "../ui/DataList.tsx"; +import { EmptyState } from "../ui/EmptyState.tsx"; +import { ErrorState } from "../ui/ErrorState.tsx"; +import { LoadingState } from "../ui/LoadingState.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { Section } from "../ui/Section.tsx"; +import { SelectField } from "../ui/SelectField.tsx"; +import { StatusBadge, type StatusTone } from "../ui/StatusBadge.tsx"; +import { TextField } from "../ui/TextField.tsx"; + +const MAX_NAME = 64; +// 期限の選択肢(秒)。API は 300〜3,600 秒を受ける +const EXPIRES = [900, 1800, 3600] as const; +const TONE: Record = { active: "success", expired: "neutral", revoked: "warning" }; +const STATE_KEY = { active: "delegations.stateActive", expired: "delegations.stateExpired", revoked: "delegations.stateRevoked" } as const; + +// エージェントへの委任の画面(#2384)。値は発行の直後に 1 度だけ出し、閉じると二度と出さない。 +// 発行と取り消しは人だけが確定でき、オフラインの間は確定させない +export function DelegationsPage() { + const { t } = useTranslation(); + const format = useFormat(); + const { auth, api, apiUrl } = useServices(); + const { online } = useConnection(); + const onFailure = useFailureHandler(); + const [rows, setRows] = useState(null); + const [loadError, setLoadError] = useState(null); + const [name, setName] = useState(""); + const [scope, setScope] = useState("read"); + const [expires, setExpires] = useState<(typeof EXPIRES)[number]>(3600); + const [issued, setIssued] = useState(null); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + const hasToken = auth.getToken() !== null; + + const load = useCallback(async () => { + setLoadError(null); + try { + setRows(await api.delegations()); + } catch (err) { + setLoadError(onFailure(err)); + } + }, [api, onFailure]); + + useEffect(() => { + if (hasToken) void load(); + }, [hasToken, load]); + + if (!hasToken) return ; + + const issue = async () => { + setMessage(null); + if (name.trim() === "") return setMessage(t("delegations.needClientName")); + setBusy(true); + try { + const { delegation, credential } = await api.createDelegation({ client_name: name, scope, expires_in_seconds: expires }); + setIssued(credential); + setName(""); + // 結果が分からない失敗の後の送り直しは、同じ鍵で前の発行を返し、値を返さない(値は発行の 1 度だけ)。 + // 黙って受けると、使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる + if (credential === null) setMessage(t("delegations.valueUnavailable")); + setRows((prev) => [delegation, ...(prev ?? []).filter((d) => d.credential_id !== delegation.credential_id)]); + } catch (err) { + setMessage(onFailure(err)); + } finally { + setBusy(false); + } + }; + + const revoke = async (credentialId: string) => { + setMessage(null); + setBusy(true); + try { + const next = await api.revokeDelegation(credentialId); + setRows((prev) => (prev ?? []).map((d) => (d.credential_id === credentialId ? next : d))); + } catch (err) { + setMessage(onFailure(err)); + } finally { + setBusy(false); + } + }; + + const example = issued && JSON.stringify({ url: `${apiUrl ?? ""}/mcp`, headers: { Authorization: `Bearer ${issued}` } }, null, 2); + + return ( + <> + +

{t("delegations.intro")}

+ {issued && ( +
+ {t("delegations.issuedNote")} +

+ {t("delegations.value")} {issued}{" "} + +

+
+            {example}
+          
+ +
+ )} +
+ setName(e.target.value)} /> + setScope(e.target.value as DelegationScope)}> + + + + setExpires(Number(e.target.value) as (typeof EXPIRES)[number])}> + {EXPIRES.map((s) => ( + + ))} + +
+ +
+
+ {!online &&

{t("delegations.offline")}

} + {message && {message}} +
+ {loadError ? ( + void load()} /> + ) : !rows ? ( + + ) : rows.length === 0 ? ( + + ) : ( + d.credential_id} + columns={[ + { key: "name", header: t("delegations.clientName"), render: (d) => d.client_name }, + { key: "scope", header: t("delegations.scope"), render: (d) => (d.scope === "read" ? t("delegations.scopeRead") : t("delegations.scopeReadWrite")) }, + { key: "created", header: t("delegations.createdAt"), render: (d) => format.dateTime(d.created_at) }, + { key: "expires", header: t("delegations.expiresAt"), render: (d) => format.dateTime(d.expires_at) }, + { key: "state", header: t("delegations.state"), render: (d) => }, + { + key: "revoke", + header: "", + render: (d) => + d.state === "active" ? ( + + ) : null, + }, + ]} + /> + )} +
+ + ); +} diff --git a/factory/web/src/pages/delegations.test.tsx b/factory/web/src/pages/delegations.test.tsx new file mode 100644 index 000000000..5087a2226 --- /dev/null +++ b/factory/web/src/pages/delegations.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { App } from "../App.tsx"; +import { createDummyServices } from "../dummy/index.ts"; +import type { LocaleSettings } from "../i18n/settings.ts"; + +afterEach(cleanup); + +const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" }; + +function show(search = "", services = createDummyServices(search)) { + render( + + + , + ); +} + +describe("エージェントへの委任(#2384)", () => { + it("発行すると値を 1 度だけ出し、閉じると二度と出さない。一覧の先頭に有効な行が足され、取り消せる", async () => { + show(); + await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code"); + await userEvent.click(screen.getByRole("button", { name: "発行する" })); + const value = (await screen.findByText(/^ndfd_/)).textContent!; + expect(screen.getByLabelText("MCP のクライアントへの登録の例").textContent).toContain(`Bearer ${value}`); + await userEvent.click(screen.getByRole("button", { name: "閉じる" })); + expect(document.body.textContent).not.toContain(value); + const rows = within(screen.getByRole("table", { name: "発行した資格情報" })).getAllByRole("row").slice(1); + expect(within(rows[0]!).getByText("有効")).toBeTruthy(); + expect(within(rows[1]!).getByText("期限切れ")).toBeTruthy(); + expect(within(rows[1]!).queryByRole("button", { name: "取り消す" })).toBeNull(); + await userEvent.click(within(rows[0]!).getByRole("button", { name: "取り消す" })); + expect(await within(rows[0]!).findByText("取り消し済み")).toBeTruthy(); + }); + + it("送り直しで前の発行が値なしで返ったら、値を出せないことと取り消して発行し直すことを知らせる", async () => { + const services = createDummyServices(""); + const create = services.api.createDelegation; + services.api.createDelegation = async (input) => ({ ...(await create(input)), credential: null }); + show("", services); + await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code"); + await userEvent.click(screen.getByRole("button", { name: "発行する" })); + expect(await screen.findByText("値はもう表示できません。この資格情報を取り消して発行し直してください")).toBeTruthy(); + expect(screen.queryByText(/^ndfd_/)).toBeNull(); + }); + + it("名前が無ければ発行しない", async () => { + show(); + await userEvent.click(await screen.findByRole("button", { name: "発行する" })); + expect(screen.getByRole("alert").textContent).toBe("名前を入力してください"); + }); + + it("オフラインの間は発行のボタンを押せない", async () => { + show("?connection=offline"); + expect(((await screen.findByRole("button", { name: "発行する" })) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText("オフラインの間は発行と取り消しができません")).toBeTruthy(); + }); + + it("まだ委任していなければ空の表示", async () => { + show("?scenario=empty"); + expect(await screen.findByText("まだ委任していません")).toBeTruthy(); + }); +}); diff --git a/factory/web/src/screens.ts b/factory/web/src/screens.ts index 6bf1dec0c..b0660841f 100644 --- a/factory/web/src/screens.ts +++ b/factory/web/src/screens.ts @@ -21,7 +21,8 @@ export type ScreenId = | "billing" | "runners" | "loops" - | "loopDetail"; + | "loopDetail" + | "delegations"; export interface Screen { id: ScreenId; @@ -52,6 +53,8 @@ export const SCREENS: readonly Screen[] = [ { id: "runners", path: null, roles: OWNERS, nav: false, status: "planned" }, { id: "loops", path: "/accounts/:accountId/repositories/:repositoryId/loops", roles: ALL_ROLES, nav: false, status: "available" }, { id: "loopDetail", path: "/accounts/:accountId/loops/:loopId", roles: ALL_ROLES, nav: false, status: "available" }, + // 委任は利用者に属し、アカウントに属さない。自分のアカウントからたどる(#2384) + { id: "delegations", path: "/delegations", roles: ALL_ROLES, nav: false, status: "available" }, ]; export type AvailableScreen = Screen & { path: string }; diff --git a/issues/issue-2199-design.md b/issues/issue-2199-design.md index fbd7a17e6..b707e342d 100644 --- a/issues/issue-2199-design.md +++ b/issues/issue-2199-design.md @@ -2,26 +2,27 @@ ## 目的 -- **何が壊れているか**: #2170 の cross-refactoring(PR #2192)が `factory/` のリファクタリングの提案 20 件(R01〜R20)を時間(`budget`)で見送り、どの課題にも残っていない。その後に重複が広がり、2026-10-31 の `develop`(ee32e92f)では `requireEnv` が 4 か所、ログインの確認が 6 画面にある -- **誰が困るか**: 同じ `factory/api/`・`factory/web/` に足していく子課題(#2171・#2172・#2173・#2174・#2189)の実装者と、その cross-refactoring。重複を写して広げ、同じ提案を毎回出し直しては時間で見送る -- **直すと何が成り立つか**: 20 件のそれぞれが「適用した」か「採らない(理由つき)」に決まり、同じ役割の関数の定義が `factory/` の中で 1 か所になる。HTTP の応答・OpenAPI の契約・DynamoDB の行の形・画面の表示は変わらない +- **今どうなっているか**: #2170 の cross-refactoring(PR #2192)は、`factory/` のリファクタリングの提案 20 件(R01〜R20)を時間(`budget`)で見送った。20 件はどの課題にも残っていない。その後に重複が広がった。2026-11-05 の `develop`(86436dace)では、`requireEnv` が 5 か所、ログインの確認が 12 画面、失敗を画面の文へ変える `onFailure(err)` が 23 行にある +- **誰に効くか**: 子課題(#2171・#2172・#2173・#2174・#2189)の実装者と、その cross-refactoring。子課題は同じ `factory/api/`・`factory/web/` に足していく。重複を写して広げ、同じ提案を毎回出し直しては時間で見送る +- **変えると何が成り立つか**: 20 件のそれぞれが「適用した」か「採らない(理由つき)」に決まり、同じ役割の関数の定義が `factory/` の中で 1 か所になる。HTTP の応答・OpenAPI の契約・DynamoDB の行の形・画面の表示は変わらない ## 適用範囲 - **働く範囲**: このリポジトリの `factory/api/src`・`factory/web/src` と `scripts/tests/test_check_markdown_links.py` だけ。配布物(`plugins/`)には届かない - **プロジェクトごとに違うもの**: 無し(`factory/` の内部の関数の置き場と引数の形だけを変える) -- **当たるモード**: `standard`。要求と受け入れ条件があり、対象には既存のテスト(`factory/` の vitest 19 ファイル・pytest 46 件)がある +- **当たるモード**: `standard`。要求と受け入れ条件があり、対象には既存のテストがある。vitest は `factory/api/src`・`factory/web/src` の 37 ファイル、pytest は `scripts/tests/test_check_markdown_links.py` の 46 件である ## あるべき姿の根拠 | 根拠 | 種類 | 何を示すか | | --- | --- | --- | | PR #2192 のリファクタリング計画のコメント「見送った提案」の 20 行(理由はすべて `budget`)と、提案の結果ファイル(`/tmp/ndf-worktrees/devbasex--ai-plugins/rf2192/work/.cross_refactoring/{claude,codex,kiro}-propose-rf2192-result.json`)の `rationale` と `plan` | 実測(記録の読み取り) | 各件の兆候と、提案した手順。この設計の判定表の出所 | -| 2026-10-31 の `develop`(ee32e92f)で `grep -rn "function requireEnv" factory/api/src` が 4 行(`handlers/api.ts`・`handlers/github-oidc.ts`・`handlers/ingest.ts`・`runs/deps.ts`) | 実測 | PR #2192 の後に写しが 2 つ増えた(要求の前提 1) | -| 同じ日の `grep -rn 'Navigate to="/login"' factory/web/src/pages` が 6 行、`grep -rn "onFailure(err)" factory/web/src` が 13 行 | 実測 | 画面の重複も PR #2192 の後に足された 3 画面(`RepositoriesPage`・`ModelCredentialPage`・`NewSprintPage`)へ写っている | -| 同じ日の `factory/api/src/github-oidc/app.ts` に `clientCredentials`・`exchangeGithubCode`・`issueIdToken` があり、POST /token の本体は 24 行 | 実測 | R07 の兆候は PR #2192 の I-001 で消えている(要求の前提 2) | -| `factory/api/src/index.ts` が `./handlers/api.ts` と `./handlers/ingest.ts` を import し、どちらも読み込みの時点で `export const handler = handle(...)` を組み立てる | 実測(コードの読み取り) | 公開の入口が Lambda の入口を読む向き(R02)。`runs/deps.ts` も `handlers/ingest.ts` を読む | -| 着手前の `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が `46 passed` | 実測(2026-10-31) | 受け入れ条件 5 の「変更の前の件数」 | +| 2026-11-05 の `develop`(86436dace)で `grep -rn "function requireEnv" factory/api/src` が 5 行(`handlers/api.ts`・`handlers/github-oidc.ts`・`handlers/ingest.ts`・`handlers/issue-requests.ts`・`runs/deps.ts`) | 実測 | PR #2192 の後に写しが 3 つ増えた(要求の前提 1) | +| 同じ日の `grep -rn 'Navigate to="/login"' factory/web/src/pages` が 12 行、`factory/web/src` の `onFailure(err)` が 13 ファイルで 23 行 | 実測 | 画面の重複が PR #2192 の後に足された画面(スプリント・ループ・委任・バックログなど)へ写っている | +| 同じ日の `factory/web/src/App.tsx` が、ログインの後の画面をすべて 1 つの枠(`AppShell` の layout route)の下に置く | 実測(コードの読み取り) | ログインの確認を 1 か所に置ける場所が既にある(R14) | +| 同じ日の `factory/api/src/github-oidc/app.ts` に `clientCredentials`・`exchangeGithubCode`・`issueIdToken` があり、POST /token の本体は 21 行 | 実測 | R07 の兆候は PR #2192 の I-001 で消えている(要求の前提 2) | +| `factory/api/src/index.ts` が `./handlers/api.ts`(`depsFromEnv`・`createApiApp`)と `./handlers/ingest.ts`(`progressStoreFromEnv`)を読み、どちらも読み込みの時点で `export const handler = ...` を組み立てる。`runs/deps.ts` も `handlers/ingest.ts` を読む | 実測(コードの読み取り) | 公開のエントリポイントが Lambda のエントリポイントを読む向き(R02) | +| 着手前の `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が `46 passed`(2026-11-05) | 実測 | 受け入れ条件 5 の「変更の前の件数」 | 要求と受け入れ条件は #2199 の本文にある(コピーは [issue-2199-requirements.md](issue-2199-requirements.md))。この文書は「どう作るか」だけを扱う。 @@ -34,7 +35,7 @@ | Neural Development Factory(`ndf-factory`) | アカウント・席・履歴・実行の形態・プラン(`factory/api/src/accounts/`)、GitHub の仲立ち(`github-oidc/`)、画面(`factory/web/`) | | NDF の cross-refactoring(`ndf-cross-refactoring`) | 見送った提案・リファクタリング計画(R01〜R20 の出所) | -関係は**順応者**である。この変更は `ndf-cross-refactoring` が残した見送った提案を、その語と兆候の分類(duplication など)のまま受け取り、`ndf-factory` の構造を直す。`ndf-cross-refactoring` の側は何も変えない。 +関係は**順応者(Conformist)**である。この変更は `ndf-cross-refactoring` が残した見送った提案を、その語と兆候の分類(duplication など)のまま受け取り、`ndf-factory` の構造を直す。`ndf-cross-refactoring` の側は何も変えない。 ### 集約 @@ -43,7 +44,7 @@ | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | | 利用者 | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | 利用者(`UserRecord`) | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | -| アカウント | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | アカウント(`AccountRecord`) | 席(`SeatRecord`) | プラン・実行の形態・席の種類・履歴の行(`HistoryRecord`) | +| アカウント | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | アカウント(`AccountRecord`) | 席(`SeatRecord`) | プラン・実行の形態・席の種類・履歴の行(`HistoryRecord`)・作成の冪等の鍵の行 | 利用者とアカウントは別の集約である(確定仕様 `docs/specifications/ndf-factory-accounts-and-plans.md` の「集約」)。2 つを 1 つのトランザクションで書くのは初めてのログインで両方を作るときだけで、それ以外の利用者の読み書きはアカウントの操作に含めない。保存の契約(`AccountStore`)が両方の集約の保存を受け持つのは今のままで、この変更は契約の置き場(`accounts/port.ts`)だけを変える。 @@ -54,11 +55,11 @@ | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | | I1 | — | HTTP の応答(状態コード・本文・エラーの文言)と画面の表示が変更の前と同じ | その件を戻し、表の扱いを「採らない(適用すると振る舞いが変わる)」にする | -| I2 | アカウント | DynamoDB へ送る `TransactItems` の項目の並び・キー・属性・条件式・プレースホルダの名前(`:plan`・`:mode` など)が変更の前と同じ | その件を戻す(C3 に当たる変更を入れない) | +| I2 | アカウント | DynamoDB へ送る `TransactItems` の項目の並び(業務の項目 → 履歴 → 作成の冪等の鍵の行)・キー・属性・条件式・プレースホルダの名前(`:plan`・`:mode` など)が変更の前と同じ | その件を戻す(C3 に当たる変更を入れない) | | I3 | アカウント | どの書き込みも履歴の行を同じトランザクションで足す(既存の I20)。条件の失敗は `ConditionFailedError`、それ以外の失敗は今と同じ型で呼ぶ側へ届く | その件を戻す | | I4 | — | 認証(`auth/verify.ts`・`github-oidc/`)と認可(`auth/resolve.ts`)の判定が変更の前と同じ | その件を戻す(C2 に当たる変更を入れない) | | I5 | — | 「適用した」件の同じ役割の関数・定数の定義が `factory/` の中で 1 か所にある | 残った写しを寄せるまで実装の Pull Request を出さない | -| I6 | — | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。テストの補助の `testing/app.ts` は除かない)が `handlers/` を import しない | import を移した先へ替えるまで出さない | +| I6 | — | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。テストの補助の `testing/app.ts` は除かない)が `handlers/` を import しない。`handlers/` の中のファイルどうしも互いを import しない | import を移した先へ替えるまで出さない | ### ドメインイベント @@ -78,8 +79,8 @@ | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 見送った提案 | cross-refactoring のリファクタリング計画の「見送った提案」の表の 1 行。ここでは PR #2192 の R01〜R20 | 変えない(`ndf-cross-refactoring` の既存の語のまま) | -| 同じ役割の写し | R01〜R20 の対象と同じ処理・同じ判断を、別の場所に書き写したもの。要求の S3 が対象に含める | 追加(`ndf-factory`) | -| 保存の契約 | `AccountStore` の interface と、その引数・失敗の型(`AccountPatch`・`AccountUpdate`・`ConditionFailedError`)。DynamoDB とメモリの 2 つの実装が満たす | 追加(`ndf-factory`) | +| 同じ役割の写し | R01〜R20 の対象と同じ処理・同じ判断を、別の場所に書き写したもの。要求の S3 が対象に含める | 登録済み(`ndf-factory`) | +| 保存の契約 | `AccountStore` の interface と、その引数・失敗の型(`AccountPatch`・`AccountUpdate`・`ConditionFailedError`)。DynamoDB とメモリの 2 つの実装が満たす | 登録済み(`ndf-factory`) | ## 機能一覧 @@ -88,47 +89,51 @@ | # | 機能 | 誰が使うか | | --- | --- | --- | | F1 | 20 件の扱い(適用した・採らない)と写しの扱いを 1 つの表で読む | 承認ゲート 2 で見る人 | -| F2 | 環境変数の読み取り・API の入口の組み立て・保存の契約・再試行を、1 か所の定義から使う | 子課題(#2171・#2172・#2173・#2174・#2189)の実装者 | +| F2 | 環境変数の読み取り・API のエントリポイントの組み立て・保存の契約・再試行・ログインの確認・失敗の表示を、1 か所の定義から使う | 子課題(#2171・#2172・#2173・#2174・#2189)の実装者 | | F3 | 後の cross-refactoring が R01〜R20 と同じ提案を出さない | cross-refactoring の提案の担当 | ## 20 件の判定表 -**この表が実装の Pull Request の本文の表(受け入れ条件 1)の下書きになる。** 「今の状態」は 2026-10-31 の `develop`(ee32e92f)で確かめた。実装の開始時に `develop` が進んでいれば、E1 として確かめ直す。 +**この表が実装の Pull Request の本文の表(受け入れ条件 1)の下書きになる。** 「今の状態」は 2026-11-05 の `develop`(86436dace)で確かめた。実装の開始時に `develop` が進んでいれば、E1 として確かめ直す。 | # | 対象 | 今の状態 | 扱い | 直し方(1 か所にまとめる場所) | 同じ役割の写し(S3) | | --- | --- | --- | --- | --- | --- | -| R01 | `routes/teams.ts#registerTeams` | 席の種類の検証と文言が `teams.ts` に直書き、`accounts.ts` に定数 | 適用する | `routes/body.ts` の `readSeatType(value)`。`SEAT_TYPE_MESSAGE` も移す | `routes/accounts.ts` の 2 か所 | -| R02 | `index.ts#depsFromEnv` | `index.ts` が `handlers/api.ts`・`handlers/ingest.ts` を読む | 適用する | 決定 1 | `runs/deps.ts` → `handlers/ingest.ts` | +| R01 | `routes/teams.ts#registerTeams` | 席の種類の検証と文言が `teams.ts` に直書き、`accounts.ts` に定数 `SEAT_TYPE_MESSAGE` | 適用する | `accounts/service.ts` の `validateSeatType(value)`(既存の `validateTeamName` の隣)。文言の定数も移す | `routes/accounts.ts` の `addSeat`・`changeSeat` | +| R02 | `index.ts#depsFromEnv` | `index.ts` が `handlers/api.ts`・`handlers/ingest.ts` を読み、`createApiApp` も `handlers/api.ts` から再 export する | 適用する | 決定 1 | `runs/deps.ts` → `handlers/ingest.ts`、`handlers/issue-requests.ts` → `handlers/ingest.ts` | | R03 | `github-oidc/app.ts#githubJson` | `/token` と `/userinfo` が `/user` の取得と ID の検証を 2 回書く | 適用する | `createGithubOidcApp` の中の `githubUser(accessToken)`(取れなければ `null`) | 無し | -| R04 | `accounts/store.ts#DynamoAccountStore.transact` | 6 つの書き込みが履歴の行の展開を末尾に書く | 適用する | 決定 3(R09 と同じ直し方) | 無し | -| R05 | `web/pages/TeamPage.tsx#TeamPage` | 席の種類の `