diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cddcac1c3..4d971717b 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.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.", + "description": "Claude Code plugin (v10.27.21): 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 bcf79892c..e2a91fea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.21] - 2026-11-05 + +- 設計の承認に「承認」と答えると、その設計の PR がマージされる(#2542)(#2543) +- 開発ループの周回の境で、承認済みの設計の PR がマージし直される(#2542)(#2543) +- 承認に答えた後、その時点の整理結果が一覧で表示される(#2542)(#2543) +- 承認の答えの `--by` 引数が契約に含まれ、検査の対象になる(#2542)(#2543) + ## [ndf 10.27.19] - 2026-11-05 - 委任の資格情報を画面から発行・取り消しできる。委任の一覧は専用のページで確認できる(#2512)(#2518) diff --git a/README.md b/README.md index 3050eb865..faeb5c7c2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.19 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.21 | 開発の作業手順(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.19** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.21** に入る 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 332d429c9..b1a94f3db 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -313,6 +313,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | テストの上限 | `limits` | test_strategy.limits が返す init_test_timeout・test_timeout・whole_timeout(秒)。ステップの timeout はこれに with_margin の余裕を足した値で、テストの上限には余裕を足さない | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | 全体テストの鍵 | `whole_key` | 戦略の全体テストのコマンドの集合から作る識別子(whole_key)。全体テストの実測をコマンドごとに分け、コマンドの違う実測を混ぜない(#1631) | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | 予算の反映 | — | 開発ループの記録の予算を、渡した単位の値で上書きすること(`sprint-state.py loop budget`)。変わったときだけ予算の履歴に 1 行足し、止まった記録の解除や周回の締めを伴わない | — | — | — | +| 予算の既定 | — | 開発ループの予算の単位ごとの既定の値。リポジトリの .ndf/pace.json の loop.budget の値で、宣言が無い単位は NDF の組み込みの値(周回 3・費用 60 USD・時間 24 時間)。Factory の画面で予算の欄を空にした単位はこの値へ戻る | — | — | — | +| 既定へ戻す指示 | — | 開発ループの予算の単位に、数の代わりに渡す値 default(`--budget-usd default`)。NDF が反映の時点の予算の既定でその単位を上書きする。Factory は予算の欄が空の単位をこの指示として実行ジョブの指示に載せる | — | — | — | | 予算の履歴 | — | 開発ループの記録が持つ、予算の上書きの記録(`budget_history`)。予算が変わったときだけ、時刻・前の値・後の値の 1 行を足す | — | — | — | | 人の判断待ちの行 | — | 開発ループの記録が持つ、人の答え(承認・差し戻し・直して出し直す)を待つ課題の行(`waiting`)。`sprint-state.py loop answer` で答えを書く。着手の前の課題の状態を指す「人の判断待ち」とは別の語 | — | — | — | | 問い合わせの区分 | `ask_category` | AskUserQuestion の metadata.source に AI が付ける値。ndf-gate:<番号>(承認ゲート)・ndf-ask:redline:<番号>(レッドライン。宣言で外せない)・ndf-ask:<名前>(宣言が許す区分)の 3 つの形。hook は質問の中身を読まず、この値が許された区分かだけを見る(#2206) | — | — | `docs/specifications/ndf-ask-guard.md` | @@ -356,7 +358,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | Skill の渡し方 | — | Skill を worker へ届ける形。プランの worker には本文を system プロンプトへ入れ(本文)、ndf:worker には SKILL.md の絶対パスを起動指示の「読む Skill」に書く(パス。#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | | 読む Skill | — | supervisor が ndf:worker の起動指示に書く項目。その作業で読む規則の Skill の名前と SKILL.md の絶対パスを並べ、worker はここに挙がった SKILL.md だけを読む(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | | 規則の Skill | — | 手順を持たず、書き方・記録の仕方のような規則だけを持つ Skill(document-style・logging-guidelines など)。Skill の対応表に置けるのはこれだけで、工程 Skill は置かない(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | -| 差分の棚卸し | — | 毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題への見積の段階の付与だけを行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | +| 差分の棚卸し | — | 毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題(起点のタグの時刻より後に作られた課題)への見積の段階の付与だけを行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 見直し | — | 数スプリントに 1 回行う棚卸しの深さ。後回しを除く open の課題の枝刈り・順位付け・マイルストーンの再編と、ニーズ・シーズの調査からの起票を行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 大整理 | — | 十数スプリントに 1 回行う棚卸しの深さ。未完了の課題すべてを原因の型で分け、Issue 単位では直らない根本原因を探し、見つかれば大方針を立てて課題とマイルストーンを組み直す。後回しの再評価を含む(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 後回しの再評価 | — | 後回しのマイルストーンの課題を、前倒し・やらない・据え置きに分けること。大整理の一部(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | @@ -374,7 +376,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 起動のブロック | `spawn_block` | conductor の応答に置く、情報文字列が ndf-spawn <名前> の囲み。中身が最初の入力になる | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | | 並列の計画 | — | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | | レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | -| オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、レーンの承認待ちを依頼として受けて問うか自分で決め(設計の承認では設計 Pull Request をマージする)、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request) | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 一時レーン | — | 並列の計画のオーガナイザーが、持ち主のいない共有の作業(まとめた配布のプランの実行・その失敗の対応・止まったレーンの保留の周回の残り)のためだけに起こすラッパー(<計画名>-rel<連番>、役割 helper)。同時に 1 本までで、作業を終えると自分で止まる | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 保留の周回 | — | 並列の計画のループのレーンで、答えを待たずに締めた周回(配布待ち・質問待ち)。課題は締め直すまで選定から外し、答えの後に残りの工程を流して 1 回だけ締め直す | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | @@ -400,6 +402,15 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | プランのハッシュ | — | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344) | — | — | — | | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | +| 外部の調査 | — | 設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順(#2519)。棚卸しのシーズの調査とは別のもの | — | — | — | +| 自前で作る決定 | — | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録。決定の作り方の行に「自前で作る」を含むもので見分ける(#2519) | — | — | — | +| 確かめられなかったこと | — | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、試した手段(URL か検索の語)と理由を並べた設計文書の欄(#2519) | — | — | — | +| 作り方 | — | 設計文書の決定の記録の中の 1 行。その決定が機能をどう得るか(自前で作る / ライブラリやサービスを使う / 標準に倣う、またはその組み合わせ)を書く(#2519) | — | — | — | +| 比べた候補 | — | 設計文書の決定の記録の中の 1 行。外部の調査の表で比べた行の番号(STD<数> / PRD<数> / LIB<数>)を並べる(#2519) | — | — | — | +| プランの進行の記録 | — | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`<状態のディレクトリ>/progress.jsonl`)。開発ループのまとめのプランでは `.ndf/loop/<名>/lap-<番号>/close*/` の下にある(#2315) | — | — | — | +| 承認した先端 | — | 設計の承認を問うた時点の設計 Pull Request の先頭のコミット。承認した設計のマージはこの先端のときだけ行う | — | — | — | +| マージの結果 | — | 承認した設計 Pull Request を宛先へマージする 1 回の試みの結果。マージした・マージ済み・先端が動いた・印が無い・検査が緑でない・衝突・宛先が本番・失敗のどれか | — | — | — | +| 答えの後の整理 | — | オーガナイザーが答えを送った後、待つ間に届いた依頼を取り直し、除くものを除いて残りをまとめて問う手順 | — | — | — | ## NDF の Slack 通知(`ndf-notification`) @@ -726,6 +737,7 @@ backlog-refinement と out-of-scope が課題を分類し、起票するとき | 棚卸しの深さ | — | 1 回の棚卸しが扱う範囲。差分の棚卸し・見直し・大整理の 3 つ。`upkeep.py` の `--depth diff\|review\|overhaul`(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 調査の項目 | — | 調査を小さく分けた単位。側面と対象の組(例: `seeds.library:pytest`)。1 項目が 1 つの調査の記録になる(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 付け直しの記録 | — | 大整理が課題の親とマイルストーンを変える前に残す、課題ごとの前の状態。`upkeep.py revert` が読んで戻す(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | +| 深さの記録 | — | 棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は、棚卸しのステップの秒・ドル・言語モデルの名前を持つ(#2315) | — | — | — | ## NDF の worktree(`ndf-worktree`) @@ -791,6 +803,7 @@ ai-plugins の開発で formatter と静的解析を手元と CI で走らせる | 古いバイトコード | — | Python が、書き換えたソースを、前の内容から作った .pyc で読むこと。.pyc はソースの更新時刻(秒)と大きさだけで新しさを照らすため、同じ秒に同じ大きさで書き換えると見分けられない | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` | | git を使うテストの土台 | — | cross-refactoring のテストで、git のリポジトリの作業ディレクトリ・pytest の起動口・状態ファイルを用意する補助(crossref_helpers.build_git_flow) | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` | | CLI の版の一覧 | — | スモークの像へ入れる 4 ランタイムの CLI の版数と取得先と checksum を書いたファイル(tests/runtime-smoke/cli-versions.env)。版を上げるときに書き換える唯一の場所 | — | — | `docs/specifications/runtime-smoke-cli-versions.md` | +| 前提のコマンドの一覧 | `required_commands` | 根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる | — | — | — | ## NDF の学習の設定(`ndf-training-optout`) @@ -926,20 +939,20 @@ NDF を Web UI から操作できるサービスとして提供するときの | Factory のオーガナイザー | — | 同じリポジトリで並行する Factory の実行をまとめる役割。1 台の中のオーガナイザーの仕事すべて(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時のレーンへ渡す)に、Factory 固有の仕事(実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したもの。API の handler と見回りの中で動き、常駐しない。1 台の中のオーガナイザーと同じ層の別の実装で、互いに排他 | — | — | `docs/specifications/ndf-factory-organizer.md` | | 配布の実行 | — | Factory のオーガナイザーが、まとめた配布 1 つのために起こすスプリントの実行。範囲のどの実行でもなく、承認資料を作り、本番の配布を流し、結果を返して終わる。同じリポジトリで同時に 1 本まで | — | — | `docs/specifications/ndf-factory-organizer.md` | | 画面の地図 | — | Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表 | — | — | `docs/specifications/ndf-factory-ui-components.md` | -| 予定の画面 | — | 画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面 | — | — | — | -| 作る課題 | — | 予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く | — | — | — | +| 予定の画面 | — | 画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面 | — | — | `docs/specifications/ndf-factory-screen-map.md` | +| 作る課題 | — | 予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く | — | — | `docs/specifications/ndf-factory-screen-map.md` | | デザインのトークン | — | 色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | ダミーの API クライアント | — | 画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 翻訳のファイル | — | 言語ごとに、画面の文言のキーと文言を持つファイル | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 見本の画面 | — | 土台の部品だけで組み、ダミーデータで動かす画面の 1 組(スプリントの一覧と詳細)。後続の課題が中身を置き換える出発点 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | つながりの状態 | — | 画面が今サーバーへつながっているか・最後に受け取った時刻・つながってから確定する操作の数の組 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 言語と地域の設定 | — | 画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める | — | — | `docs/specifications/ndf-factory-ui-components.md` | -| 委任の資格情報 | — | 人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントかを持ち、人が取り消せる(#2384) | — | — | — | -| 人だけが確定できる操作 | — | 承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る(#2384) | — | — | — | -| 操作の記録 | — | Factory の利用者向けの操作 1 回ごとに 1 件追記する記録。だれが・いつ・どの呼び出し元から・何を行ったかを持ち、書き換えと削除の経路を持たない(#2384) | — | — | — | -| 呼び出し元の種類 | — | 操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ(#2384) | — | — | — | -| 機能から起こした契約 | — | 経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた API の契約。取得・作成・更新・削除とも配列で受け渡す(#2384) | — | — | — | -| 操作の扱い | — | Factory の利用者向けの操作が要る範囲。読み取り(read)・書き込み(write)・人だけ(human)の 3 つで、契約の各操作に x-ndf-access として持つ(#2384) | — | — | — | -| 操作の表 | — | Factory の契約から生成する、利用者向けの操作の一覧(operationId・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する(#2384) | — | — | — | -| 要素の結果 | — | Factory の配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(code)を持つ(#2384) | — | — | — | -| 作成の冪等の鍵 | — | Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384) | — | — | — | +| 委任の資格情報 | — | 人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントかを持ち、人が取り消せる(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 人だけが確定できる操作 | — | 承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 操作の記録 | — | Factory の利用者向けの操作 1 回ごとに 1 件追記する記録。だれが・いつ・どの呼び出し元から・何を行ったかを持ち、書き換えと削除の経路を持たない(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 呼び出し元の種類 | — | 操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 機能から起こした契約 | — | 経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた API の契約。取得・作成・更新・削除とも配列で受け渡す(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 操作の扱い | — | Factory の利用者向けの操作が要る範囲。読み取り(read)・書き込み(write)・人だけ(human)の 3 つで、契約の各操作に x-ndf-access として持つ(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 操作の表 | — | Factory の契約から生成する、利用者向けの操作の一覧(operationId・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 要素の結果 | — | Factory の配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(code)を持つ(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | +| 作成の冪等の鍵 | — | Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 620f61027..2a2094058 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4565,6 +4565,18 @@ "meaning": "開発ループの記録の予算を、渡した単位の値で上書きすること(`sprint-state.py loop budget`)。変わったときだけ予算の履歴に 1 行足し、止まった記録の解除や周回の締めを伴わない", "pending_source": "issues/issue-2405-design.md" }, + { + "term": "予算の既定", + "context": "ndf-workflow", + "meaning": "開発ループの予算の単位ごとの既定の値。リポジトリの .ndf/pace.json の loop.budget の値で、宣言が無い単位は NDF の組み込みの値(周回 3・費用 60 USD・時間 24 時間)。Factory の画面で予算の欄を空にした単位はこの値へ戻る", + "pending_source": "issues/issue-2406-requirements.md" + }, + { + "term": "既定へ戻す指示", + "context": "ndf-workflow", + "meaning": "開発ループの予算の単位に、数の代わりに渡す値 default(`--budget-usd default`)。NDF が反映の時点の予算の既定でその単位を上書きする。Factory は予算の欄が空の単位をこの指示として実行ジョブの指示に載せる", + "pending_source": "issues/issue-2406-design.md" + }, { "term": "予算の履歴", "context": "ndf-workflow", @@ -5087,13 +5099,13 @@ "term": "予定の画面", "context": "ndf-factory", "meaning": "画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面", - "pending_source": "issues/issue-2431-requirements.md" + "source": "docs/specifications/ndf-factory-screen-map.md" }, { "term": "作る課題", "context": "ndf-factory", "meaning": "予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く", - "pending_source": "issues/issue-2431-requirements.md" + "source": "docs/specifications/ndf-factory-screen-map.md" }, { "term": "デザインのトークン", @@ -5135,60 +5147,60 @@ "term": "委任の資格情報", "context": "ndf-factory", "meaning": "人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントかを持ち、人が取り消せる(#2384)", - "pending_source": "issues/issue-2384-requirements.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "人だけが確定できる操作", "context": "ndf-factory", "meaning": "承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る(#2384)", - "pending_source": "issues/issue-2384-requirements.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "操作の記録", "context": "ndf-factory", "meaning": "Factory の利用者向けの操作 1 回ごとに 1 件追記する記録。だれが・いつ・どの呼び出し元から・何を行ったかを持ち、書き換えと削除の経路を持たない(#2384)", - "pending_source": "issues/issue-2384-requirements.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "呼び出し元の種類", "context": "ndf-factory", "meaning": "操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ(#2384)", - "pending_source": "issues/issue-2384-requirements.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "機能から起こした契約", "context": "ndf-factory", "meaning": "経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた API の契約。取得・作成・更新・削除とも配列で受け渡す(#2384)", - "pending_source": "issues/issue-2384-requirements.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "操作の扱い", "context": "ndf-factory", "meaning": "Factory の利用者向けの操作が要る範囲。読み取り(read)・書き込み(write)・人だけ(human)の 3 つで、契約の各操作に x-ndf-access として持つ(#2384)", - "pending_source": "issues/issue-2384-design.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "操作の表", "context": "ndf-factory", "meaning": "Factory の契約から生成する、利用者向けの操作の一覧(operationId・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する(#2384)", - "pending_source": "issues/issue-2384-design.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "要素の結果", "context": "ndf-factory", "meaning": "Factory の配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(code)を持つ(#2384)", - "pending_source": "issues/issue-2384-design.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "作成の冪等の鍵", "context": "ndf-factory", "meaning": "Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384)", - "pending_source": "issues/issue-2384-design.md" + "source": "docs/specifications/ndf-factory-agent-access.md" }, { "term": "差分の棚卸し", "context": "ndf-workflow", - "meaning": "毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題への見積の段階の付与だけを行う(#2315)", + "meaning": "毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題(起点のタグの時刻より後に作られた課題)への見積の段階の付与だけを行う(#2315)", "source": "docs/specifications/ndf-backlog-refinement-depths.md" }, { @@ -5320,7 +5332,7 @@ { "term": "オーガナイザー", "context": "ndf-workflow", - "meaning": "--parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別", + "meaning": "--parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、レーンの承認待ちを依頼として受けて問うか自分で決め(設計の承認では設計 Pull Request をマージする)、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別", "source": "plugins/ndf/skills/development-workflow/references/parallel.md" }, { @@ -5486,6 +5498,73 @@ "context": "ndf-workflow", "meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの", "pending_source": "issues/issue-2369-design.md" + }, + { + "term": "外部の調査", + "context": "ndf-workflow", + "meaning": "設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順(#2519)。棚卸しのシーズの調査とは別のもの", + "pending_source": "issues/issue-2519-requirements.md" + }, + { + "term": "自前で作る決定", + "context": "ndf-workflow", + "meaning": "既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録。決定の作り方の行に「自前で作る」を含むもので見分ける(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "確かめられなかったこと", + "context": "ndf-workflow", + "meaning": "外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、試した手段(URL か検索の語)と理由を並べた設計文書の欄(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "作り方", + "context": "ndf-workflow", + "meaning": "設計文書の決定の記録の中の 1 行。その決定が機能をどう得るか(自前で作る / ライブラリやサービスを使う / 標準に倣う、またはその組み合わせ)を書く(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "比べた候補", + "context": "ndf-workflow", + "meaning": "設計文書の決定の記録の中の 1 行。外部の調査の表で比べた行の番号(STD<数> / PRD<数> / LIB<数>)を並べる(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "深さの記録", + "context": "ndf-issue-upkeep", + "meaning": "棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は、棚卸しのステップの秒・ドル・言語モデルの名前を持つ(#2315)", + "pending_source": "issues/issue-2315-requirements.md" + }, + { + "term": "プランの進行の記録", + "context": "ndf-workflow", + "meaning": "supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`<状態のディレクトリ>/progress.jsonl`)。開発ループのまとめのプランでは `.ndf/loop/<名>/lap-<番号>/close*/` の下にある(#2315)", + "pending_source": "issues/issue-2315-requirements.md" + }, + { + "term": "前提のコマンドの一覧", + "code": "required_commands", + "context": "repo-dev-checks", + "meaning": "根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる", + "pending_source": "issues/issue-2346-design.md" + }, + { + "term": "承認した先端", + "context": "ndf-workflow", + "meaning": "設計の承認を問うた時点の設計 Pull Request の先頭のコミット。承認した設計のマージはこの先端のときだけ行う", + "pending_source": "issues/issue-2507-design.md" + }, + { + "term": "マージの結果", + "context": "ndf-workflow", + "meaning": "承認した設計 Pull Request を宛先へマージする 1 回の試みの結果。マージした・マージ済み・先端が動いた・印が無い・検査が緑でない・衝突・宛先が本番・失敗のどれか", + "pending_source": "issues/issue-2507-design.md" + }, + { + "term": "答えの後の整理", + "context": "ndf-workflow", + "meaning": "オーガナイザーが答えを送った後、待つ間に届いた依頼を取り直し、除くものを除いて残りをまとめて問う手順", + "pending_source": "issues/issue-2507-design.md" } ] } diff --git a/docs/metrics/ndf-token-usage/2026-11-05-3.json b/docs/metrics/ndf-token-usage/2026-11-05-3.json new file mode 100644 index 000000000..013ce8245 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05-3.json @@ -0,0 +1,404 @@ +{ + "per_pr": [ + { + "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": 11, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.19", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 12, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 22, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.18", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 8, + "cost": 556393.4500000001, + "context": 4158480.125, + "out": 21703.75, + "minutes": 14.78743750055631, + "p": 59717.375, + "k": 32.625, + "w5": 0.0, + "w1h": 123018.875, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 386626, + "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": 11, + "cost": 310729.60454545455, + "context": 1877802.7272727273, + "out": 10600.181818181818, + "minutes": 13.177489394130129, + "p": 56969.27272727273, + "k": 18.727272727272727, + "w5": 0.0, + "w1h": 84000.81818181818, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 860700, + "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": 3, + "cost": 25845.816666666666, + "context": 13853.333333333334, + "out": 789.0, + "minutes": 0.1638888888888889, + "k": 1.0, + "w5": 0.0, + "w1h": 10875.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": 5, + "cost": 271428.7, + "context": 1602899.8, + "out": 11501.2, + "minutes": 2.4896666666666665, + "k": 18.0, + "w5": 0.0, + "w1h": 68587.0 + }, + { + "version": "10.27.19", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 12, + "cost": 589657.6, + "context": 4071082.1666666665, + "out": 24671.416666666668, + "minutes": 25.923666667607097, + "p": 61902.333333333336, + "k": 31.416666666666668, + "w5": 0.0, + "w1h": 134710.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 137219, + "rewrite_tokens_after_5m": 137219, + "read_tokens_after_5m": 1335649, + "rewrite_gap_median": 119.93733333349228 + }, + { + "version": "10.27.19", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 81701.55, + "context": 38176.0, + "out": 1293.0, + "minutes": 0.25666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 37603.0 + }, + { + "version": "10.27.19", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 4, + "cost": 29530.3875, + "context": 14010.75, + "out": 1171.0, + "minutes": 0.20666666666666664, + "k": 1.0, + "w5": 0.0, + "w1h": 11781.0 + }, + { + "version": "10.27.19", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 7, + "cost": 406760.64999999997, + "context": 2946882.714285714, + "out": 20203.285714285714, + "minutes": 10.394285714285717, + "k": 31.714285714285715, + "w5": 0.0, + "w1h": 81202.0 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 22, + "cost": 359155.76363636367, + "context": 2281245.409090909, + "out": 13089.545454545454, + "minutes": 6.145312879302285, + "p": 62656.818181818184, + "k": 21.272727272727273, + "w5": 0.0, + "w1h": 92104.86363636363, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 433549, + "rewrite_gap_median": null + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 28709.0, + "context": 13478.0, + "out": 351.0, + "minutes": 0.08499999999999999, + "k": 1.0, + "w5": 0.0, + "w1h": 13476.0 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 6, + "cost": 46902.38333333333, + "context": 29780.166666666668, + "out": 1507.0, + "minutes": 0.305, + "k": 1.0, + "w5": 0.0, + "w1h": 19423.833333333332 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 4, + "cost": 19474.6375, + "context": 13710.75, + "out": 1019.25, + "minutes": 0.25083333333333335, + "k": 1.0, + "w5": 0.0, + "w1h": 7021.0 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 14, + "cost": 321881.71785714285, + "context": 2480525.0714285714, + "out": 16455.785714285714, + "minutes": 2.9858321422622316, + "p": 24603.714285714286, + "k": 28.642857142857142, + "w5": 96268.42857142857, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 57188, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.5998999973138174 + }, + { + "version": "10.27.19", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 17, + "cost": 155429.82352941178, + "context": 774343.5882352941, + "out": 7741.35294117647, + "minutes": 4.139705882352941, + "k": 18.294117647058822, + "w5": 0.0, + "w1h": 39987.94117647059 + } + ], + "external": [], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.18", + "until": "2026-11-05T10:43:00Z", + "sessions": 53, + "sessions_with_pr": 0, + "unlinked_external": 3157, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 915, + "版を判定できない": 21 + }, + "released": "10.27.21", + "previous": "2026-11-05-2.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-05-3.md b/docs/metrics/ndf-token-usage/2026-11-05-3.md new file mode 100644 index 000000000..21fdf6006 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05-3.md @@ -0,0 +1,101 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-05 集計・10.27.21 の配布) + +**10.27.21 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-05T10:43:00Z`。表の最初の行は 10.27.18(前の記録: 2026-11-05-2.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.21 --until 2026-11-05T10:43:00Z --min-version 10.27.18 +python3 scripts/token-usage.py --min-version 10.27.18 --until 2026-11-05T10:43:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.18, 10.27.19, 10.27.21。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.19。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(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.18 | conductor | 19 | 58.1k | 24.6 | 0.00M | 0.10M | 0 | 0 | 0.00 | +| 10.27.18 | supervisor | 8 | 24.9k | 1.5 | 0.02M | 0.01M | 0 | 0 | 0.00 | +| 10.27.18 | worker | 5 | - | 18.0 | 0.00M | 0.07M | - | - | - | +| 10.27.19 | conductor | 34 | 62.4k | 24.9 | 0.00M | 0.11M | 1 | 1 | 0.03 | +| 10.27.19 | supervisor | 30 | 24.6k | 13.9 | 0.04M | 0.01M | 2 | 0 | 0.14 | +| 10.27.19 | worker | 24 | - | 22.2 | 0.00M | 0.05M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 53 件 / PR を作った会話: 0 件 / 寄せ先の無い外部 CLI: 3157 件 / 寄せ先の無い帳簿の行: 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.18 | conductor | - | - | 19 | 0.41M | 2.84M | 15.3k | 13.9 | +| 10.27.18 | supervisor | mvv | claude -p | 1 | 0.07M | 0.03M | 1.1k | 0.2 | +| 10.27.18 | supervisor | pr | claude -p | 3 | 0.03M | 0.01M | 0.8k | 0.2 | +| 10.27.18 | supervisor | その他 | ndf:worker | 4 | 0.07M | 0.07M | 3.7k | 0.7 | +| 10.27.18 | worker | work | claude -p | 5 | 0.27M | 1.60M | 11.5k | 2.5 | +| 10.27.19 | conductor | - | - | 34 | 0.44M | 2.91M | 17.2k | 13.1 | +| 10.27.19 | supervisor | judge | claude -p | 1 | 0.03M | 0.01M | 0.4k | 0.1 | +| 10.27.19 | supervisor | mvv | claude -p | 7 | 0.05M | 0.03M | 1.5k | 0.3 | +| 10.27.19 | supervisor | pr | claude -p | 8 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.19 | supervisor | その他 | ndf:worker | 14 | 0.32M | 2.48M | 16.5k | 3.0 | +| 10.27.19 | worker | work | claude -p | 24 | 0.23M | 1.41M | 11.4k | 6.0 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.18 | conductor | - | - | 19 | 58.1k | 24.6 | 0.00M | 0.10M | 0 | 0 | 0.00M | 1.25M | - | +| 10.27.18 | supervisor | mvv | claude -p | 1 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.18 | supervisor | pr | claude -p | 3 | - | 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 | 5 | - | 18.0 | 0.00M | 0.07M | - | - | - | - | - | +| 10.27.19 | conductor | - | - | 34 | 62.4k | 24.9 | 0.00M | 0.11M | 1 | 1 | 0.14M | 1.77M | 119.9 | +| 10.27.19 | supervisor | judge | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.19 | supervisor | mvv | claude -p | 7 | - | 1.0 | 0.00M | 0.02M | - | - | - | - | - | +| 10.27.19 | supervisor | pr | claude -p | 8 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.19 | supervisor | その他 | ndf:worker | 14 | 24.6k | 28.6 | 0.10M | 0.00M | 2 | 0 | 0.00M | 0.00M | 0.6 | +| 10.27.19 | worker | work | claude -p | 24 | - | 22.2 | 0.00M | 0.05M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | diff --git a/docs/specifications/README.md b/docs/specifications/README.md index cc1b95016..d2a2f855e 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -130,5 +130,7 @@ | [ndf-parallel-organizer.md](ndf-parallel-organizer.md) | 並列の計画(plan.json)を 1 度書き、--parallel のオーガナイザーが耐久ワークフローでレーンを起動・観測し、本番の配布を develop のコミットの範囲で 1 本にまとめ、区分つきの問いを集める設計判断・常に成り立つ条件・メッセージとイベントの形・置き場。手順は development-workflow の references/parallel.md と issue-plan-strategy の references/parallel-plan.md が正 | | [ndf-factory-organizer.md](ndf-factory-organizer.md) | Neural Development Factory: Factory のオーガナイザー(同じリポジトリで並行する実行の本番の配布を配布の実行 1 本にまとめる・待ちの判定・止まった配布と再開・承認と問いのリポジトリごとの一覧・NDF の切り替え口・手元のマシンと料金プランの語)。#2439 | | [ndf-factory-sprint-views.md](ndf-factory-sprint-views.md) | Neural Development Factory: スプリントの一覧と詳細・工程の中身・バックログの読み出し(事象から導く派生・フェーズの名前の対応づけ・ランナーが送る工程の中身と伏せ字・経路の形・読み出しの口・画面と読み直し・常に成り立つ条件)。#2172 | +| [ndf-script-shared-helpers.md](ndf-script-shared-helpers.md) | NDF のスクリプトの共通処理の置き場(使用量の行・時刻の読み取り・git の根・再起動の行・正の整数の判定)と、契約の抽出・パッケージ内の揃い・呼び出し元の数を理由に寄せない重複、長い関数の分け方。手順は `refactoring` の SKILL.md が正 | +| [ndf-factory-agent-access.md](ndf-factory-agent-access.md) | Neural Development Factory: エージェントからの操作(委任の資格情報の発行・照合・取り消しと表・人だけが確定できる 10 の操作・要求の検査の順・MCP の受け口とツールの定義・呼び出し元の種類・追記だけの操作の記録と許可した鍵・作成の冪等の鍵の行・環境変数)。#2384 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-factory-agent-access.md b/docs/specifications/ndf-factory-agent-access.md new file mode 100644 index 000000000..a1df7d10c --- /dev/null +++ b/docs/specifications/ndf-factory-agent-access.md @@ -0,0 +1,294 @@ +# Neural Development Factory: エージェントからの操作(委任の資格情報・MCP・操作の記録) + +人が画面で発行した委任の資格情報で、AI のエージェントや連携するシステムが、画面と同じ操作を API と MCP(Model Context Protocol)から行う。認可は人の直の操作と同じで、人だけが確定できる 10 の操作は委任から断る。利用者向けの操作は 1 回ごとに操作の記録へ 1 件を追記する。利用者向けの操作の入出力の形(要素の配列・要素の結果・要求全体の誤り・作成の冪等の鍵)は、[契約の定義と進捗の受け口](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) が持つ。 + +## 概要 + +- **委任の資格情報**は、人が画面(`/delegations`)で発行する不透明な値 `ndfd_<資格情報の ID>_<秘密>` である。範囲は読み取りだけ(`read`)か読み書き(`read_write`)、期限は発行から 300〜3,600 秒である。要求の `Authorization: Bearer <値>` で受ける +- **MCP の受け口**は既存の API の Lambda の `POST /mcp` にある。ツールは契約から生成した操作の表から作り、呼び出しは同じアプリの経路へ内部の要求として送る。認証・範囲の照合・要素の検査・認可・記録は、画面と API と同じ処理を通る +- **操作の記録**は追記だけの表で、だれが・いつ・どの経路で・どの操作を・どの対象に行い、結果がどうだったかを持つ。秘密と個人情報の値は持たない + +変更はコンテキスト Neural Development Factory(`ndf-factory`)の中で閉じる。外のエージェントとクライアントは、契約(OpenAPI)と、そこから作った MCP のツールの定義だけでやり取りする(公開された言語)。 + +## 用語 + +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「委任の資格情報」「人だけが確定できる操作」「操作の記録」「呼び出し元の種類」「機能から起こした契約」「操作の扱い」「操作の表」「要素の結果」「作成の冪等の鍵」である。 + +## 背景 + +- **MCP の委任の認証は、Factory が発行した資格情報を `Authorization` で直に受ける。** MCP の仕様(2026-07-28 版)は認可を任意とし、独自の認証の方式を認めている。この形なら人が画面で発行した値を MCP のクライアントの設定へコピーするだけで使える。MCP の認可の仕様(OAuth 2.1)に沿わせるには認可サーバーが要り、Cognito はクライアントの動的な登録を持たない +- **委任の資格情報は不透明な値にし、要求のたびに表を強い整合で読む。** 取り消しをその後の最初の要求から効かせるためである。署名した token(JWT)も取り消しには表を読むことになり、照合の結果をキャッシュすると取り消しの直後の要求を通してしまう +- **MCP の SDK は `@modelcontextprotocol/server`(2.4 系)を使う。** MCP の 2026-07-28 版に対応し、Web 標準の要求と応答で動く受け口を持つため、Hono の経路の上で新しい実行基盤を足さずに受けられる + +## 対象範囲 + +- **対象**: 契約に操作の扱い(`x-ndf-access`)を持つ利用者向けの 37 の操作(アカウント 7・取り込みとキーとスプリント 17・開発ループ 7・オーガナイザー 3・委任 3)と、`POST /mcp` +- **対象外**: ランナー向けの経路(`/run-jobs/*`)・進捗の受け口(`/ingest/*`)・GitHub の Webhook。どれも操作の扱いを持たず、委任の資格情報を受けず、操作の記録を書かない + +## 仕様 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | 値オブジェクト | +| --- | --- | --- | --- | +| 委任の資格情報 | 委任の管理(`factory/api/src/delegations/`) | 委任の資格情報 | 範囲・期限・秘密のハッシュ・クライアントの名前・作成の冪等の鍵 | +| 操作の記録 | 操作の記録の書き手(`factory/api/src/audit/`) | 記録 1 件 | 呼び出し元の種類・結果・対象の ID の配列 | + +委任の資格情報は利用者を ID で参照し、アカウントへの権限を持たない。要求のたびに利用者から、人の直の操作と同じ関数でアカウントを解決する。 + +### 構成要素 + +| 要素 | 責務 | 置き場 | +| --- | --- | --- | +| 資格情報の検証 | `Authorization` の値を、委任の資格情報(頭が `ndfd_`)か Cognito の access token として検証し、利用者・委任の有無・呼び出し元の種類を要求の文脈に置く | `factory/api/src/auth/resolve.ts` | +| 委任の管理 | 委任の資格情報の発行・一覧・取り消しと、値の照合 | `factory/api/src/delegations/service.ts` | +| 操作の登録の層 | 経路を操作の表から登録し、範囲の照合・要素の数と重複の検査・経路のアカウントの解決・操作の記録へ渡す値を、すべての利用者向けの操作に同じく掛ける | `factory/api/src/routes/operation.ts` | +| 要素ごとの実行 | 要素を並べる数 10 でユースケースへ渡し、失敗を要素の結果に包む | `factory/api/src/routes/elements.ts` | +| 操作の記録の書き手 | 許可した鍵だけで記録を組み、追記する | `factory/api/src/audit/middleware.ts`・`log.ts` | +| MCP の受け口 | `POST /mcp` で MCP の要求を受け、ツールの呼び出しを同じアプリの経路へ送る | `factory/api/src/mcp/server.ts` | +| 委任の画面 | 委任の資格情報を発行・一覧・取り消す([画面の地図](ndf-factory-screen-map.md) の `/delegations`) | `factory/web/src/pages/DelegationsPage.tsx` | + +```mermaid +graph TD + W[画面の API
クライアント] --> V[資格情報の検証] + X[エージェント・
連携するシステム] --> V + X --> M[MCP の受け口] + M -->|内部の要求| V + V --> D[委任の管理] + V --> R[操作の登録の層] + R --> E[要素ごとの実行] + E --> U[既存のユースケース] + R --> A[操作の記録の書き手] +``` + +```mermaid +classDiagram + class Caller { + +userId + +idp + +delegation: credentialId・scope | null + +via: screen | api | mcp + } + class Operation { + +operationId + +method + +path + +kind: list | select | create | update | delete | action + +access: read | write | human + } + class AuditRecord { + +recordId + +at + +userId + +credentialId + +via + +operationId + +accountId + +targetIds + +outcome + +status + +reason + } + class AuditLog { + <> + +append(record) + } + AuditLog ..> AuditRecord : 追記する +``` + +### 要求の検査の順 + +利用者向けの要求は、次の順に検査する。どこで断っても、操作の記録を 1 件追記してから応答を返す。 + +| 順 | 検査 | 当たったときの応答 | +| --- | --- | --- | +| 1 | 資格情報が有効か(形・秘密・期限・取り消し) | 401 `unauthorized` | +| 2 | 操作の表にある経路か | 404 | +| 3 | 委任の資格情報で、扱い `human` の操作か | 403 `human_only` | +| 4 | 範囲 `read` の委任の資格情報で、扱い `write` の操作か | 403 `scope_insufficient` | +| 5 | 要素が 1〜100 個で、同じ鍵の要素が無いか(一覧には掛けない) | 400 `empty_items`・`too_many_items`・`duplicate_key` | +| 6 | 経路のアカウントを解決できるか | 403 `forbidden` | +| 7 | 要素ごとに人と同じ認可を掛けてユースケースを呼ぶ | 200 と要素の結果の配列 | +| 8 | 操作の記録を追記できたか | 失敗なら 503 `audit_unavailable` | + +### 人だけが確定できる操作 + +扱い `human` の操作は次の 10 である。委任の資格情報では範囲によらず 403 `human_only` で断り、答えも状態も保存しない。 + +| 何に当たるか | 操作 | +| --- | --- | +| 承認と判断の答え | `answerLoopPending`・`resolveStalledRelease` | +| モデルの認証情報の保存と削除(共通原則の C1) | `putModelCredential`・`deleteModelCredential` | +| 席の追加・変更・削除とチームの作成(C2) | `addSeat`・`changeSeat`・`removeSeat`・`createTeam` | +| 委任の資格情報の発行と取り消し | `createDelegation`・`revokeDelegation` | + +リポジトリの登録と外す操作、実行の形態の変更は扱い `write` である。登録は GitHub の画面で人が認可した結果(`proof`)が無いと通らない。課題の候補(`postIssueCandidates`)は言語モデルを呼んで費用が掛かるため、読むだけでも扱い `write` である。承認と通知(#2173)が足す承認の操作も扱い `human` で足す。 + +### 委任の操作 + +| operationId | メソッドと経路 | 要素か条件 | 成功の値 | +| --- | --- | --- | --- | +| `listDelegations` | `GET /delegations` | — | `{credential_id, client_name, scope, created_at, expires_at, revoked_at, state}` の配列。自分が発行したものだけを新しい順で 100 件まで | +| `createDelegation` | `POST /delegations` | `{client_name, scope, expires_in_seconds, idempotency_key}` | `{delegation, credential}`。`credential` は値で、発行の応答で 1 度だけ返す。同じ冪等の鍵の 2 回目は同じ資格情報を返し、`credential` を `null` にする | +| `revokeDelegation` | `DELETE /delegations` | `?credential_id=` を繰り返す | 取り消した資格情報の値(一覧の 1 行と同じ形) | + +- `client_name` は 1〜64 字、`scope` は `read` か `read_write`、`expires_in_seconds` は 300〜3,600 の整数である。外れた要素は 400 の要素の結果になり、保存しない +- `state` は `active`(有効)・`expired`(期限切れ)・`revoked`(取り消し済み)である +- 取り消しは、期限切れと取り消し済みの資格情報では何も変えずに成功を返す。他人の資格情報と無い資格情報は、同じ `forbidden` の要素の結果になる + +```mermaid +stateDiagram-v2 + [*] --> 有効: 人が画面で発行する + 有効 --> 期限切れ: 期限の日時を過ぎる + 有効 --> 取り消し済み: 人が画面で取り消す + 期限切れ --> [*] + 取り消し済み --> [*] +``` + +有効へ戻る遷移と期限の延長は無い。延ばしたいときは新しく発行する。 + +### MCP の受け口 + +| 項目 | 規則 | +| --- | --- | +| 経路 | `POST /mcp`。状態を持たず、要求ごとに JSON の 1 応答で返す。`GET` と `DELETE` の `/mcp` は 405 | +| 資格情報 | 委任の資格情報だけを受ける。Cognito の token は 401。`Origin` のヘッダーがあり画面のオリジンと違えば 403 | +| ツールの名前 | `operationId` のまま。37 の操作すべてを登録する | +| ツールの説明 | 契約の `summary` と `description`。扱い `human` の操作は、頭に「人だけが確定できる。MCP からは常に断られる。画面で行う。」を足す | +| 入力の定義 | オブジェクト。経路に `{accountId}` があれば `account_id`(必須)。一覧・指定の取得・削除は query の項目、作成・更新・操作は `items`(要素の配列。1〜100 個) | +| 呼び出し | 入力から経路・query・本文を組み、`Authorization` をそのまま付けて同じアプリへ内部の要求として送る。MCP の受け口が受け取る依存は、アプリの要求の関数(Hono の `app.request`)1 つだけである | +| 結果 | `structuredContent` に `{status, body}`、`content` に同じ値の JSON の文字列を 1 つ。`isError` は状態が 400 以上のとき真 | + +ツールの定義は起動のたびに操作の表から作り、手で書いた定義を持たない。扱い `human` のツールも登録するのは、エージェントが断られた理由と、人が画面で行うことを読めるようにするためである。 + +### 呼び出し元の種類 + +| 種類 | 決め方 | +| --- | --- | +| `screen` | Cognito の access token での要求(画面の外から使っても人の直の操作として扱う) | +| `api` | 委任の資格情報で API へ直に送った要求 | +| `mcp` | MCP の受け口が送る内部の要求。印は Hono の要求の環境(`app.request` の第 3 引数)に置く | + +外から届くヘッダーでは種類を変えられない。 + +### 常に成り立つ条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| A1 | 委任の資格情報 | 期限は発行から 300〜3,600 秒の間にある | 発行の要素を 400 で断り、保存しない | +| A2 | 委任の資格情報 | 期限を過ぎた・取り消した資格情報は認証に通らない。取り消しはその後の最初の要求から効く | 401 で、操作を行わない | +| A3 | 委任の資格情報 | 秘密は SHA-256 のハッシュだけを保存し、平文を持たない。照合は一定時間の比較で行う | 保存の関数の引数に平文を取らない | +| A4 | 委任の資格情報 | 委任の資格情報では、人だけが確定できる操作を呼べない | 範囲によらず 403 `human_only` | +| A5 | 委任の資格情報 | 範囲が読み取りだけの資格情報では、書き込みの操作を呼べない | 403 `scope_insufficient` | +| A6 | 委任の資格情報 | 委任の資格情報での認可の判定は、発行した人の直の操作の判定と同じになる | 利用者とアカウントの解決を人と同じ関数で行い、委任の側に認可の分岐を持たない | +| A7 | 操作の記録 | 利用者向けの操作 1 回に記録が 1 件増える。成功・一部の失敗・400・401・403・404 のどれでも増える | 追記に失敗したら操作を 503 `audit_unavailable` で返す | +| A8 | 操作の記録 | 記録を書き換える・消す関数を持たない | 保存の契約は追記の関数 1 つだけを持つ | +| A9 | 操作の記録 | 記録に秘密と個人情報の値が入らない | 記録の対象の ID は許可した鍵の一覧からだけ取る | +| A10 | 機能から起こした契約 | 利用者向けの操作と MCP のツールが 1 対 1 で対応し、画面の API クライアントが呼ぶ操作はすべて契約にある | 検査(`contracts/tests/operations.test.ts`・`web/scripts/check-source.ts`)が落ちる | +| A11 | 機能から起こした契約 | MCP のツールは同じアプリの経路を呼び、保存の関数を直に持たない | MCP の受け口に渡す依存をアプリの要求の関数 1 つに限る | +| A12 | 操作の記録 | 呼び出し元の種類は、要求のヘッダーで変えられない | 種類を資格情報の種類と内部の印だけで決める | + +## データ・設定 + +### 委任の資格情報の表 + +区分の鍵は `credential_id` だけである。利用者の一覧のため GSI `user_id-created_at` を置く。資格情報の読み出しは強い整合で行う。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `credential_id` | 文字列 | 許さない | 主キー。ランダムな 16 バイトを base32(小文字・埋めなし、26 字)で書いたもの | +| `user_id` | 文字列 | 許さない | 委任した利用者(Cognito の `sub`) | +| `idp` | 文字列 | 許さない | 委任した利用者の IdP。人の解決と同じ `Caller` を組むために持つ | +| `client_name` | 文字列 | 許さない | 人が付けたクライアントの名前(1〜64 字)。操作の記録には写さない | +| `scope` | 文字列 | 許さない | `read` / `read_write` | +| `secret_hash` | 文字列 | 許さない | 秘密(32 バイト)の SHA-256 の 16 進 | +| `created_at` | 文字列 | 許さない | 発行の日時(UTC、ISO 8601) | +| `expires_at` | 文字列 | 許さない | 期限(UTC) | +| `revoked_at` | 文字列 | 許す | 空は取り消していない。取り消しで 1 度だけ書く | + +`revoked_at` の 1 回の書き込み以外は行を書き換えず、期限を過ぎた行も消さない。操作の記録が資格情報の ID だけを持つため、後からクライアントの名前をたどれるようにする。 + +### 操作の記録の表 + +区分の鍵は `record_id` だけである。書き込みは `attribute_not_exists(record_id)` の条件つきの追加だけで、更新と削除を行わない。検索の索引は持たない。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `record_id` | 文字列 | 許さない | 主キー。API Gateway の要求の ID(無ければ UUID)。構造化ログの同じ要求の行とたどれる | +| `at` | 文字列 | 許さない | 受けた日時(UTC、ミリ秒まで) | +| `user_id` | 文字列 | 許す | 操作した利用者。空は 401 で利用者を決められなかった | +| `credential_id` | 文字列 | 許す | 委任の資格情報の ID。空は人の直の操作か、401 で決められなかった | +| `via` | 文字列 | 許さない | 呼び出し元の種類(`screen` / `api` / `mcp`) | +| `operation_id` | 文字列 | 許す | 契約の `operationId`。空は契約に無い経路 | +| `account_id` | 文字列 | 許す | 経路のアカウントの ID。空はアカウントを持たない操作 | +| `target_ids` | 文字列の配列 | 許さない | 要素の鍵と作った識別子を `<鍵の名前>=<値>` で並べる。0 件は空の配列 | +| `outcome` | 文字列 | 許さない | `ok`(すべての要素が成功)/ `partial`(一部の要素が失敗)/ `rejected`(要求全体を断った) | +| `status` | 数 | 許さない | 応答の HTTP の状態 | +| `reason` | 文字列 | 許す | `rejected` の誤りの `code`。`partial` は失敗した要素の `code` を重ねずに `,` で並べる。`ok` は空 | + +`target_ids` に写してよい鍵は `account_id`・`user_id`・`repository_id`・`sprint_run_id`・`step_key`・`loop_id`・`issue`・`seq`・`credential_id`・`idempotency_key` の 10 だけである(正は `factory/contracts/src/operations.ts` の `AUDIT_KEYS`)。一覧に無い項目は、要求と応答のどこにあっても記録に写さない。対応の検査は、すべての操作の `x-ndf-keys` と `x-ndf-id` がこの一覧に入っていることを確かめる。MCP の受け口そのもの(`/mcp`)への要求と `OPTIONS` は操作ではないため記録しない(内部の要求をそれぞれ記録する)。 + +### 作成の冪等の鍵の行 + +新しい識別子を作る 4 つの作成(スプリントの実行・開発ループ・チーム・委任の資格情報)で、作るものと同じ表へ同じトランザクションで書く。 + +| 置く表 | 鍵 | 値 | +| --- | --- | --- | +| 実行の表(スプリントの実行・開発ループ)・アカウントの表(チーム) | `IDEMP###<鍵>` | 作った識別子・作った日時・`ttl` | +| 委任の資格情報の表 | 区分の鍵 `credential_id` に同じ形の値 | 同じ | + +`ttl` は作った日時の 24 時間後(UNIX 秒)である。期限は TTL の削除に頼らず、書き込みは条件 `attribute_not_exists(<区分の鍵>) OR ttl < :now` で行い、読み出しは `ttl` が今の時刻より前の行を無いものとして扱う。DynamoDB の TTL は期限を過ぎた行をすぐには消さないためである。 + +### 環境変数 + +| 名前 | 意味 | 無いとき | +| --- | --- | --- | +| `DELEGATIONS_TABLE_NAME` | 委任の資格情報の表 | 委任の資格情報を 401 にし、委任の操作を 503 にする | +| `AUDIT_TABLE_NAME` | 操作の記録の表 | 記録を構造化ログへ `{"audit": <記録>}` の 1 行で出す | + +表・GSI・TTL の属性・権限・`POST /mcp` の経路の定義はインフラ(private リポジトリ)が持つ。API の Lambda に与える権限は、委任の資格情報の表の `GetItem`・`PutItem`・`UpdateItem`・`Query`(GSI)と、操作の記録の表の `PutItem` だけである。操作の記録の表へ `UpdateItem`・`DeleteItem` を与えない。 + +## エラー処理 + +- **資格情報の表を読めない**: 503 `delegation_unavailable` +- **記録の追記の失敗**: 構造化ログへ記録の全体を `{"audit_fallback": true, "record": <記録>}` の 1 行で出してから 503 `audit_unavailable` を返す。書き込みの操作は既に行われていることがあるため、呼ぶ側は同じ要求を送り直す。作成は冪等の鍵で、更新・削除・取り消しは同じ値の書き込みとして、送り直しで 2 重にならない +- **要素の中の保存の不調**: その要素だけを 503 の要素の結果にする。1 要素の失敗で要求全体を投げると、1 度しか返せない委任の値など、ほかの要素の成功の結果が失われるためである + +## セキュリティ + +- 委任の資格情報の値は発行の応答で 1 度だけ返し、表には秘密の SHA-256 だけを置く +- 委任の資格情報の期限は 1 時間以内で、取り消しはその後の最初の要求から効く(照合の結果をキャッシュしない) +- 人だけが確定できる操作は、操作の表の扱いでユースケースを呼ぶ前に断る +- 操作の記録は許可した鍵だけで組む。チームの名前・クライアントの名前・課題の題と本文・モデルの認証情報・GitHub の `code`・`state`・`proof` は記録に入らない + +## 運用 + +- 画面は見えている間 30 秒ごとに読み直すため、開いた画面 1 つで 1 時間に 120 件の記録になる +- 記録の保存期間と、利用者からの削除の求めへの応じ方は決めていない。外部の利用者へ公開する前に決める +- 1 時間を超えて動くエージェントは、人が期限ごとに発行し直す。更新の token で解くときは MCP の認可の仕様へ移り、委任の資格情報の表・範囲・人だけの操作の判定はそのまま使う + +## テスト観点 + +- 画面の API クライアントが組む要求の `operationId` がすべて操作の表にあり、経路の文字列を直に書いた要求が無いこと +- 操作の表の利用者向けの操作と、`tools/list` が返すツールの名前の集合が一致すること +- MCP のツールの呼び出しが API の経路と同じユースケースを通り、同じ結果を返すこと +- 人の token と同じ人の委任の資格情報で同じ操作を呼び、許可と拒否が一致すること +- 期限の後と取り消しの後の最初の要求が 401 で、ユースケースが呼ばれないこと +- 範囲 `read` で書き込みの操作が 403 `scope_insufficient` になること +- 扱い `human` の 10 の操作を範囲 `read_write` で呼び、403 `human_only` で何も保存されないこと +- 委任の資格情報でスプリントの詳細と承認待ちの一覧が読めること +- 期限 299 秒と 3,601 秒の発行が 400 で保存されないこと。保存した行に秘密の平文が無く、ハッシュが値と一致すること +- 同じ冪等の鍵の委任の発行 2 回で同じ ID を返し、2 回目の `credential` が `null` であること +- 成功・一部の失敗・400・401・403 のどれでも記録が 1 件増えること。保存の契約の関数が追記 1 つだけであること +- 秘密と個人情報を含む要求を送り、記録のどの値にもそれらが無いこと +- 外から `x-ndf-via: mcp` などのヘッダーを付けた API の要求が `api` として記録されること +- ランナー向けの経路と受け口の既存のテストが変えずに通ること + +テストは `factory/api/src/delegations/delegations.test.ts`・`audit/audit.test.ts`・`mcp/mcp.test.ts`・`routes/elements.test.ts`・`factory/contracts/tests/operations.test.ts` にある。 + +## 関連リンク + +- [契約の定義と進捗の受け口](ndf-factory-contracts-and-progress.md)(利用者向けの操作の形) +- [認証とアカウント・席・料金プラン](ndf-factory-accounts-and-plans.md) +- [画面の地図](ndf-factory-screen-map.md) +- [MCP の仕様 2026-07-28 版の認可](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization.md) diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 6c5990a20..f55c982cf 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)で画面を組む。予定の行に作る課題として書かれた課題は、行を足さずにその行の状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ括弧を消す)。予定の行は、どれも作る課題を `(作る課題: #<番号> <題>)` の形で「載る機能」の列の末尾に 1 つ持つ。作る課題は、書く時点で GitHub で open のものに限る。作る課題がその画面を作らないと決めたときは、同じ変更で行ごと消す。別の課題がその行を作ると決めたときは、その課題の変更で作る課題を自分の番号と題へ書き換える。 ## 概要 diff --git a/docs/specifications/ndf-script-shared-helpers.md b/docs/specifications/ndf-script-shared-helpers.md new file mode 100644 index 000000000..48845b46d --- /dev/null +++ b/docs/specifications/ndf-script-shared-helpers.md @@ -0,0 +1,114 @@ +# NDF のスクリプトの共通処理の置き場と、意図して寄せない重複 + +NDF のスクリプト(`plugins/ndf/scripts/` と、Skill の `skill-stats`・`backlog-refinement` のスクリプト)のうち、 +文脈の量の記録・トークンの監視の hook・課題の棚卸し・Skill の利用統計で同じ処理が 2 か所以上に現れていたものを、 +1 か所の関数へ寄せている。この文書は、寄せた処理の置き場と呼び出しの向き、寄せずに残した重複とその理由、 +長い関数を分けた形を残す。 + +## 概要 + +- 同じ処理(使用量の行の組み立て・時刻の読み取り・git のリポジトリの根の取得・再起動の行の組み立て・ + 正の整数の判定・「実行した」行の判定)は、本体を `plugins/` の中の 1 か所に置く +- 寄せる先は、重複の片方がすでにある既存のモジュールである。共通処理のためだけのモジュールは作らない +- 寄せても、コマンドの引数・終了コード・標準出力の結果 JSON・hook の出力の文面・記録のファイルの形・ + `plugins/ndf/contracts/` の契約は変わらない +- 契約の抽出・パッケージ内の書き方の揃い・呼び出し元の数を理由に、寄せずに残す重複がある(「寄せない重複」) + +### 用語 + +| 用語 | 意味 | +| --- | --- | +| 見送った提案 | cross-refactoring が計画に入れなかった提案と、見送った改善項目を合わせたもの | +| 現状固定テスト | 用語集([docs/glossary.md](../glossary.md))の定義に従う | +| 判定の記録 | 文脈の量の方針が工程の境界ごとに書く記録(`*.decisions.jsonl`)。形は [ndf-context-restart-policy.md](ndf-context-restart-policy.md) が持つ | + +### コンテキスト + +| コンテキスト | この文書で扱う範囲 | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 判定の記録・文脈の量の方針・トークンの監視の hook・スプリントの周回の選定 | +| NDF の課題の棚卸し(`ndf-issue-upkeep`) | 調査・深さ・大整理(`upkeep_*.py`) | +| NDF の cross-refactoring(`ndf-cross-refactoring`) | 見送った提案の出所。この文書の構造はその語と兆候の分類を受け取る(順応者) | + +## 仕様 + +### 常に成り立つ条件 + +- 同じ処理の本体は `plugins/` の中で 1 か所にある(下の「共通処理の置き場」の表の処理) +- `plugins/` の Python は、リポジトリだけで使う `scripts/` のモジュールを import しない +- 共通処理を使う側を変えても、記録の行のキーと値、標準出力、`plugins/ndf/contracts/` は変わらない。 + 変わるなら、その寄せ方は採らない + +### 共通処理の置き場 + +| 処理 | 置き場(関数) | 使う側 | +| --- | --- | --- | +| API 呼び出し 1 回の使用量の行(`ts`・`model`・`inp`・`out`・`read`・`w5`・`w1h`・`context`)を組む | `lib/context_decisions.py` の `usage_call` | 同じモジュールの `read_calls`(会話の記録)、`context-policy.py` の `ledger_calls`(使用量の帳簿。秒の `seconds` だけを呼ぶ側が足す) | +| 時刻の文字列を UNIX 時刻(秒)にする | `lib/clock.py` の `epoch_seconds`(`clock.parse` の上に置く) | `lib/context_decisions.py` の `parse_ts`(`epoch_seconds` の別名)、`backlog-refinement/scripts/upkeep_research.py` | +| git のリポジトリの根を取る | `lib/context_decisions.py` の `toplevel` | `context-policy.py`、`hook_lib/token_guard.py`(文脈の量の判定と、計画の案内の `plan_hint`) | +| 再起動の行と印の行を組む(セッションの読み取り・境目の行・`cut` と再起動の付与) | `lib/context_decisions.py` の `record_row` | `context-policy.py` の `cmd_record`、同じモジュールの `record_restart`(`sprint-state.py next`) | +| 正の整数(`bool` を除く)か判定する | `lib/context_policy.py` の `is_positive_int` | 同じモジュールの方針の解決、`lib/context_decisions.py` の `read_window` | +| 試行方式の拒否の句で使う方針と理由の語 | `lib/context_policy.py` の既存の定数 `TRIAL`・`REASON_CAPACITY`・`REASON_COST` | `hook_lib/token_guard.py` の `_trial_cause` | +| 「その深さを実行した」行の判定と、最後に実行した行の位置 | `backlog-refinement/scripts/upkeep_depth.py` の `_is_ran`・`_last_ran` | 同じファイルの `diffs_since`・`_ran`・`_due_overhaul` | + +**`clock.parse` は前後の空白と小文字の `z` も読む。** NDF の記録の時刻は `clock.now_iso` が書くため、 +この 2 つを含む時刻は記録に現れず、記録を読む結果は変わらない。形の違う時刻の読み取り +(`"null"` も読むもの、UTC へ変えるもの)は同じ処理ではないため寄せない。 + +**hook は `context_decisions` を関数の中で import する。** トークンの監視の hook は起動のたびに読まれるため、 +最上位で import すると、計画の案内が要らない呼び出しでも読み込みの時間がかかる。 +応答時間は既存の締切のテスト(`plugins/ndf/scripts/tests/test_hook_deadline.py`)が見る。 + +### 寄せない重複 + +次の重複は、意図して残している。 + +| 対象 | 残す理由 | +| --- | --- | +| `context_decisions.py` の `append_decision`・`write_applied` の書き込み先のパス | 契約のチェック(`scripts/contracts_lib/extract_py.py`)は、書き込みの式に書かれた f 文字列から書き込み先のファイル名(`*.decisions.jsonl`・`*.applied.json`)を拾う。パスを作る関数へ寄せると契約から書き込み先が消える | +| `context-policy.py` の `IDLE_CAP` と `scripts/token-usage.py` の `IDLE_CAP`・`parse_ts` | 相手は配布の外の `scripts/` にある。`plugins/` は `scripts/` を import しないため、共有の仕組みは置かない | +| `context_decisions.py` の `attach_marks` と、`context-policy.py` の区間の作り方 | 本体は重複しない。作業の報告(`report_work`)は帳簿にあるセッションを除くなど、区間の作り方を意図して変えている | +| `relay_lib/terminal.py` の `Terminal` が環境変数で待ち時間を読む形 | 待ち時間を環境変数で調整するのは `relay_lib` 全体の約束で、同じ読み方が他に 12 か所ある。1 クラスだけ引数で受けると同じパッケージに 2 つの書き方が残り、生成の時点で読むと読む時刻も変わる | +| `upkeep_research.py` の `run_research` の 11 個の引数 | 引数は待つ条件・記録の持ち主・試験用の差し替えに分かれ、1 つの組として渡っていない。値の型にまとめても関数が受ける情報は変わらず、呼び出し元(CLI 1 か所・試験 12 か所)だけが変わる | +| `upkeep_research.py` の `research_one` の入れ子 | 入れ子は 1 段で、深い入れ子に当たらない | + +### 長い関数の分け方 + +長い関数は、同じファイルの中の名前の付いた関数の並びにしている。分けた先は次のとおり。 + +| 関数 | 抜き出した手順 | +| --- | --- | +| `context-policy.py` の `report_work` | 区間の作成(`_session_spans`)・部分の合算(`_sum_sessions`)・再起動の数え上げ(`_restarts_by_reason`)・やり直しの数え上げ(`_rework`) | +| `context-policy.py` の `session_parts` | 印の境目の取得(`_mark_bounds`)・サブエージェントの加算(`_add_subagents`)・帳簿の加算(`_add_ledger`) | +| `context_decisions.py` の `read_calls` | 自動の要約の検出(`_is_auto_compact`)・最初の発言の取得(`_user_text`)・JSON 行の読み取り(`_json_lines`)、使用量の行は `usage_call` | +| `sprint_loop_select.py` の `run_selection` | 課題を選べなかったときの止まり方(`_stop_without_issue`)・周回の行を作って記録する(`_open_lap`) | +| `skill-stats.py` の `aggregate_by_project` | 利用者の発言 1 件の数え上げ(`_count_user_text`) | +| `skill-stats.py` の `emit_json`・`emit_markdown` | `--agents` の報告の値(記録の行・要約・層ごとの合計・総計・役割ごとの使用量・要約から除いた数・上限と出所)を 1 つの値の型 `AgentReport` で受ける。値は `agent_report` が組む | +| `upkeep_overhaul.py` の `count_types` | 型ごとの件数(`_issue_types`)・本文からの部品の拾い出し(`_components`)・月ごとの数え上げ(`_months`)。戻す辞書のキーと順序は変えない | +| `upkeep_research.py` の `run_research` | 空きを待って止まる理由を返す(`_wait_turn`)・経過を書く関数を作る(`_ticker`) | +| `scripts/token-usage.py` の `read_codex`(配布の外) | Codex の記録の行の読み取り(`_codex_lines`)・使用量の組み立て(`_codex_usage`) | + +## 運用 + +- 上の表の処理を直すときは、置き場の関数だけを直す。使う側へ同じ処理を書き足さない +- 寄せない重複の表にある箇所を寄せたくなったら、残す理由が今も成り立つかを先に確かめる。 + 書き込み先のパスを寄せる場合は、先に契約の抽出(`scripts/contracts_lib/extract_py.py`)の側を直す + +## テスト観点 + +- 寄せた処理を使う関数の出力が、寄せる前と同じであること。使用量の行・再起動の行と標準出力・ + 自動の要約と引継ぎ開始の数え上げは `plugins/ndf/scripts/tests/test_context_policy_hook.py` が、 + 時刻の読み取りは `plugins/ndf/scripts/tests/test_lib_clock.py` が見る +- 作業の報告の秒・費用・再起動の数、Skill の利用統計のトリガーとヒットの数とエージェントの報告の JSON、 + 直しの周回の印が変わらないこと(`test_context_policy_hook.py`・`skill-stats/tests/test_agents_report.py`・ + `skill-stats/tests/test_skill_stats_wrappers.py`・`plugins/ndf/scripts/tests/test_sprint_loop.py`) +- hook が締切の中で応答すること(`plugins/ndf/scripts/tests/test_hook_deadline.py`) +- `python3 scripts/check-ndf-contracts.py` で `plugins/ndf/contracts/` に差分が出ないこと +- `python3 scripts/check-script-structure.py` が通ること(公開の関数名が他のモジュールの同じ名前と衝突しない) + +## 関連リンク + +- [ndf-context-restart-policy.md](ndf-context-restart-policy.md) — 判定の記録と `context-policy.py` の契約 +- [ndf-token-waits-and-context-cut.md](ndf-token-waits-and-context-cut.md) — トークンの監視の hook の判定 +- [ndf-backlog-refinement-depths.md](ndf-backlog-refinement-depths.md) — 課題の棚卸しの深さ +- `plugins/ndf/skills/refactoring/SKILL.md` — テストで守ってから構造を変える手順 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 5b5e194da..be1180be4 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.19` | 利用者が常用してよい | +| 正式版 | `10.27.21` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.19` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.21` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/factory/api/src/accounts/conflict.ts b/factory/api/src/accounts/conflict.ts new file mode 100644 index 000000000..fc9913dbc --- /dev/null +++ b/factory/api/src/accounts/conflict.ts @@ -0,0 +1,39 @@ +import { AccountUnavailableError } from "../errors.ts"; +import { ConditionFailedError } from "./port.ts"; + +// 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」) +const VERSION_RETRIES = 3; + +// 読み直しと判定の 1 回分の結果。書かずに終わる(done)か、書いて終わる(write が成功したら result) +type ConflictStep = { done: T } | { write: () => Promise; result: T }; + +// 版の競合(条件の失敗)なら step から読み直して上限の回数まで書き直す。step の中で投げた誤りはそのまま届く。 +// 条件以外の書き込みの失敗は onOther の結果を投げる(既定は AccountUnavailableError) +export async function retryOnConflict( + step: () => Promise>, + onOther: (err: unknown) => unknown = () => new AccountUnavailableError(), +): Promise { + for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + const next = await step(); + if ("done" in next) return next.done; + try { + await next.write(); + return next.result; + } catch (err) { + if (!(err instanceof ConditionFailedError)) throw onOther(err); + } + } + throw new AccountUnavailableError(); +} + +// 再試行しない書き込み。成功なら undefined、条件の失敗なら onCondition の結果を返す(onCondition は投げてもよい)。 +// 条件以外の失敗は AccountUnavailableError +export async function writeOr(write: () => Promise, onCondition: () => T | Promise): Promise { + try { + await write(); + return undefined; + } catch (err) { + if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); + } + return onCondition(); +} diff --git a/factory/api/src/accounts/memory-store.ts b/factory/api/src/accounts/memory-store.ts index b3eb4d659..a35cf3d75 100644 --- a/factory/api/src/accounts/memory-store.ts +++ b/factory/api/src/accounts/memory-store.ts @@ -1,4 +1,4 @@ -import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import { ConditionFailedError, type AccountStore, type AccountUpdate } from "./port.ts"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; @@ -109,13 +109,7 @@ export class MemoryAccountStore implements AccountStore { await this.write("removeSeat", () => this.seatUnchanged(expected), () => this.seats.delete(key), history); } - async updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ) { + async updateAccount({ accountId, expectedVersion, patch, updatedAt, history }: AccountUpdate) { await this.write( "updateAccount", () => this.accounts.get(accountId)?.version === expectedVersion, diff --git a/factory/api/src/accounts/port.ts b/factory/api/src/accounts/port.ts new file mode 100644 index 000000000..3c2c190bf --- /dev/null +++ b/factory/api/src/accounts/port.ts @@ -0,0 +1,50 @@ +// アカウントの保存の契約(AccountStore)と、その引数・失敗の型。DynamoDB とメモリの実装が満たす。AWS SDK を読まない +import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts"; + +// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える +export class ConditionFailedError extends Error { + constructor() { + super("condition failed"); + this.name = "ConditionFailedError"; + } +} + +export interface AccountPatch { + plan?: AccountRecord["plan"]; + executionMode?: AccountRecord["executionMode"]; +} + +// アカウントの更新の 1 回分。version が expectedVersion と等しいときだけ patch を当て、version を 1 増やす +export interface AccountUpdate { + accountId: string; + expectedVersion: number; + patch: AccountPatch; + updatedAt: string; + history: HistoryRecord[]; +} + +// アカウントの表への読み書き。どの書き込みも履歴の行を同じトランザクションで足す(I20) +export interface AccountStore { + getUser(userId: string): Promise; + // 利用者の行が無いときだけ、利用者・個人のアカウント・履歴を書く(I12) + createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]): Promise; + getAccount(accountId: string): Promise; + getSeat(accountId: string, userId: string): Promise; + listSeats(accountId: string): Promise; + // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 + // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う + listMemberships(userId: string): 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 と等しい)のときだけ変える + updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]): Promise; + // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ外す + removeSeat(expected: SeatRecord, history: HistoryRecord[]): Promise; + // version が expectedVersion と等しいときだけ変え、version を 1 増やす + updateAccount(update: AccountUpdate): Promise; +} diff --git a/factory/api/src/accounts/seat-service.ts b/factory/api/src/accounts/seat-service.ts new file mode 100644 index 000000000..703c545e7 --- /dev/null +++ b/factory/api/src/accounts/seat-service.ts @@ -0,0 +1,90 @@ +import { ConflictError, ForbiddenError, NotFoundError, UnprocessableError } from "../errors.ts"; +import type { AccountContext, HistoryRecord, SeatRecord, SeatType } from "./model.ts"; +import type { AccountStore } from "./port.ts"; +import { retryOnConflict, writeOr } from "./conflict.ts"; + +// 席の履歴の行。null の側は席が無いことを表す +export function seatHistory( + type: "seat.added" | "seat.changed" | "seat.removed", + accountId: string, + at: string, + actor: string, + userId: string, + before: SeatType | null, + after: SeatType | null, +): HistoryRecord { + return { + accountId, + type, + at, + actor, + before: before === null ? null : { user_id: userId, seat_type: before }, + after: after === null ? null : { user_id: userId, seat_type: after }, + }; +} + +function seatNotFound(): NotFoundError { + return new NotFoundError("seat_not_found", "その席はありません"); +} + +// チームの席の足し・変え・外し(I17) +export class SeatService { + constructor( + private readonly store: AccountStore, + private readonly now: () => Date, + ) {} + + private stamp(): string { + return this.now().toISOString(); + } + + // 席の操作は持ち主だけ(I17)。持ち主でなければ席の有無にかかわらず先に 403 + private requireTeamOwner(ctx: AccountContext): void { + if (ctx.role !== "owner") throw new ForbiddenError(); + if (ctx.kind !== "team") throw new ConflictError("not_a_team", "チームのアカウントではありません"); + } + + async addSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { + this.requireTeamOwner(ctx); + if (!(await this.store.getUser(userId))) { + throw new UnprocessableError("user_not_found", "その利用者はまだログインしていません"); + } + const at = this.stamp(); + const seat: SeatRecord = { accountId: ctx.accountId, userId, seatType, addedAt: at }; + await writeOr( + () => this.store.addSeat(seat, [seatHistory("seat.added", ctx.accountId, at, ctx.userId, userId, null, seatType)]), + () => { + throw new ConflictError("seat_exists", "その利用者は既にこのチームの席を持っています"); + }, + ); + return seat; + } + + async changeSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { + this.requireTeamOwner(ctx); + // 読んだ席のままのときだけ書く。競合したら読み直し、履歴の before を作り直す + return retryOnConflict(async () => { + const current = await this.store.getSeat(ctx.accountId, userId); + if (!current) throw seatNotFound(); + const at = this.stamp(); + const history = seatHistory("seat.changed", ctx.accountId, at, ctx.userId, userId, current.seatType, seatType); + return { write: () => this.store.updateSeat(current, seatType, [history]), result: { ...current, seatType } }; + }); + } + + async removeSeat(ctx: AccountContext, userId: string): Promise { + this.requireTeamOwner(ctx); + const account = await this.store.getAccount(ctx.accountId); + if (account?.ownerUserId === userId) { + throw new ConflictError("owner_seat_protected", "チームの持ち主の席は外せません"); + } + // 読んだ席のままのときだけ外す。外して足し直した新しい席を古い要求で消さない + await retryOnConflict(async () => { + const current = await this.store.getSeat(ctx.accountId, userId); + if (!current) throw seatNotFound(); + const at = this.stamp(); + const history = seatHistory("seat.removed", ctx.accountId, at, ctx.userId, userId, current.seatType, null); + return { write: () => this.store.removeSeat(current, [history]), result: undefined }; + }); + } +} diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 85de493e0..be3b4c1d8 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -1,16 +1,9 @@ import { randomUUID } from "node:crypto"; -import { - AccountUnavailableError, - ConflictError, - ForbiddenError, - InvalidRequestError, - NotFoundError, - PlanError, - UnprocessableError, -} from "../errors.ts"; +import { AccountUnavailableError, ConflictError, ForbiddenError, InvalidRequestError, PlanError } from "../errors.ts"; import { INITIAL_EXECUTION_MODE, isPlan, + isSeatType, planFitsKind, type AccountContext, type AccountKind, @@ -18,18 +11,16 @@ import { type Caller, type ExecutionMode, type HistoryRecord, - type HistoryType, type Plan, type SeatRecord, type SeatType, type UserRecord, } from "./model.ts"; -import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import type { AccountPatch, AccountStore } from "./port.ts"; +import { retryOnConflict, writeOr } from "./conflict.ts"; +import { SeatService, seatHistory } from "./seat-service.ts"; import { claimOf, type IdempotencyRef } from "../idempotency.ts"; -// 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」) -const VERSION_RETRIES = 3; - export interface ServiceDeps { store: AccountStore; now?: () => Date; @@ -40,21 +31,19 @@ export class AccountService { private readonly store: AccountStore; private readonly now: () => Date; private readonly newId: () => string; + private readonly seats: SeatService; constructor(deps: ServiceDeps) { this.store = deps.store; this.now = deps.now ?? (() => new Date()); this.newId = deps.newId ?? randomUUID; + this.seats = new SeatService(this.store, this.now); } private stamp(): string { return this.now().toISOString(); } - private hist(accountId: string, type: HistoryType, at: string, actor: string, before: unknown, after: unknown): HistoryRecord { - return { accountId, type, at, actor, before, after }; - } - // 新しいアカウントの初期値(I24)。形態は INITIAL_EXECUTION_MODE、版は 1 から始める private newAccount(kind: AccountKind, plan: Plan, ownerUserId: string, name: string | null, at: string): AccountRecord { return { @@ -71,12 +60,14 @@ export class AccountService { } private accountCreated(account: AccountRecord, actor: string, extra?: object): HistoryRecord { - return this.hist(account.accountId, "account.created", account.createdAt, actor, null, { - kind: account.kind, - plan: account.plan, - execution_mode: account.executionMode, - ...extra, - }); + return { + accountId: account.accountId, + type: "account.created", + at: account.createdAt, + actor, + before: null, + after: { kind: account.kind, plan: account.plan, execution_mode: account.executionMode, ...extra }, + }; } // 利用者の行を読み、無ければ利用者と個人のアカウント(Free・local)を 1 つのトランザクションで作る(I12・I24、E2) @@ -91,16 +82,15 @@ export class AccountService { personalAccountId: account.accountId, createdAt: at, }; - try { - await this.store.createUserWithPersonalAccount(user, account, [this.accountCreated(account, caller.userId)]); - return user; - } catch (err) { - if (err instanceof ConditionFailedError) { - const raced = await this.store.getUser(caller.userId); - if (raced) return raced; - } - throw new AccountUnavailableError(); - } + const raced = await writeOr( + () => this.store.createUserWithPersonalAccount(user, account, [this.accountCreated(account, caller.userId)]), + async () => { + const found = await this.store.getUser(caller.userId); + if (!found) throw new AccountUnavailableError(); + return found; + }, + ); + return raced ?? user; } // 要求の利用者と経路のアカウントの ID から、権限つきの文脈を返す(決定 4、I18) @@ -171,155 +161,107 @@ export class AccountService { 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 }), - ], - 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); + const won = await writeOr( + () => + this.store.createTeam( + account, + seat, + [ + this.accountCreated(account, caller.userId, { name }), + seatHistory("seat.added", account.accountId, at, caller.userId, caller.userId, null, seatType), + ], + ref && claimOf(ref, account.accountId, at), + ), + async () => { + // 同じ鍵の作成が同時に走り、先に書いた方が勝った + const id = ref ? await this.store.getIdempotent(ref, at) : null; + if (!id) throw new AccountUnavailableError(); + return this.resolveAccount(caller, id); + }, + ); + return won ?? this.context(account, caller.userId, seatType); } - // 席の操作は持ち主だけ(I17)。持ち主でなければ席の有無にかかわらず先に 403 - private requireTeamOwner(ctx: AccountContext): void { - if (ctx.role !== "owner") throw new ForbiddenError(); - if (ctx.kind !== "team") throw new ConflictError("not_a_team", "チームのアカウントではありません"); + // 席の操作は SeatService が持つ + addSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { + return this.seats.addSeat(ctx, userId, seatType); } - async addSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { - this.requireTeamOwner(ctx); - if (!(await this.store.getUser(userId))) { - throw new UnprocessableError("user_not_found", "その利用者はまだログインしていません"); - } - const at = this.stamp(); - const seat: SeatRecord = { accountId: ctx.accountId, userId, seatType, addedAt: at }; - try { - await this.store.addSeat(seat, [ - this.hist(ctx.accountId, "seat.added", at, ctx.userId, null, { user_id: userId, seat_type: seatType }), - ]); - } catch (err) { - if (err instanceof ConditionFailedError) { - throw new ConflictError("seat_exists", "その利用者は既にこのチームの席を持っています"); - } - throw new AccountUnavailableError(); - } - return seat; + changeSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { + return this.seats.changeSeat(ctx, userId, seatType); } - async changeSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { - this.requireTeamOwner(ctx); - // 読んだ席のままのときだけ書く。競合したら読み直し、履歴の before を作り直す - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { - const current = await this.store.getSeat(ctx.accountId, userId); - if (!current) throw seatNotFound(); - const at = this.stamp(); - try { - await this.store.updateSeat(current, seatType, [ - this.hist(ctx.accountId, "seat.changed", at, ctx.userId, { user_id: userId, seat_type: current.seatType }, { - user_id: userId, - seat_type: seatType, - }), - ]); - return { ...current, seatType }; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); - } - - async removeSeat(ctx: AccountContext, userId: string): Promise { - this.requireTeamOwner(ctx); - const account = await this.store.getAccount(ctx.accountId); - if (account?.ownerUserId === userId) { - throw new ConflictError("owner_seat_protected", "チームの持ち主の席は外せません"); - } - // 読んだ席のままのときだけ外す。外して足し直した新しい席を古い要求で消さない - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { - const current = await this.store.getSeat(ctx.accountId, userId); - if (!current) throw seatNotFound(); - const at = this.stamp(); - try { - await this.store.removeSeat(current, [ - this.hist(ctx.accountId, "seat.removed", at, ctx.userId, { user_id: userId, seat_type: current.seatType }, null), - ]); - return; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); + removeSeat(ctx: AccountContext, userId: string): Promise { + return this.seats.removeSeat(ctx, userId); } // 実行の形態を変える。持ち主だけ(I22)。Free で managed は断る(I21) async setExecutionMode(ctx: AccountContext, mode: ExecutionMode): Promise { if (ctx.role !== "owner") throw new ForbiddenError(); - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + return retryOnConflict(async () => { const account = await this.store.getAccount(ctx.accountId); if (!account) throw new ForbiddenError(); - if (account.executionMode === mode) return account; + if (account.executionMode === mode) return { done: account }; if (mode === "managed" && account.plan === "free") { throw new ConflictError("managed_not_allowed_on_free", "Free の料金プランでは managed(サービスの Bedrock)を選べません"); } const at = this.stamp(); - try { - await this.store.updateAccount(account.accountId, account.version, { executionMode: mode }, at, [ - this.hist(account.accountId, "execution_mode.changed", at, ctx.userId, account.executionMode, mode), - ]); - return { ...account, executionMode: mode, version: account.version + 1, updatedAt: at }; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); + const history: HistoryRecord = { + accountId: account.accountId, + type: "execution_mode.changed", + at, + actor: ctx.userId, + before: account.executionMode, + after: mode, + }; + return { + write: () => + this.store.updateAccount({ accountId: account.accountId, expectedVersion: account.version, patch: { executionMode: mode }, updatedAt: at, history: [history] }), + result: { ...account, executionMode: mode, version: account.version + 1, updatedAt: at }, + }; + }); } // アカウントの料金プランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) // free へ下げるとき形態が managed なら、同じトランザクションで local へ変える(I21) async setPlan(accountId: string, plan: Plan): Promise { if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らない料金プランです: ${String(plan)}`); - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { - const account = await this.store.getAccount(accountId); - if (!account) throw new PlanError("account_not_found", "アカウントがありません"); - if (!planFitsKind(account.kind, plan)) { - throw new PlanError("plan_kind_mismatch", `${account.kind} のアカウントに ${plan} は置けません`); - } - if (account.plan === plan) return; - const at = this.stamp(); - const patch: AccountPatch = { plan }; - const history = [this.hist(accountId, "plan.changed", at, "system", account.plan, plan)]; - if (plan === "free" && account.executionMode === "managed") { - patch.executionMode = "local"; - history.push(this.hist(accountId, "execution_mode.changed", at, "system", "managed", "local")); - } - try { - await this.store.updateAccount(accountId, account.version, patch, at, history); - return; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw err; - } - } - throw new AccountUnavailableError(); + // 条件以外の書き込みの失敗は包まずにそのまま返す(呼ぶ側の #2174 が受ける) + await retryOnConflict( + async () => { + const account = await this.store.getAccount(accountId); + if (!account) throw new PlanError("account_not_found", "アカウントがありません"); + if (!planFitsKind(account.kind, plan)) { + throw new PlanError("plan_kind_mismatch", `${account.kind} のアカウントに ${plan} は置けません`); + } + if (account.plan === plan) return { done: undefined }; + const at = this.stamp(); + const patch: AccountPatch = { plan }; + const history: HistoryRecord[] = [{ accountId, type: "plan.changed", at, actor: "system", before: account.plan, after: plan }]; + if (plan === "free" && account.executionMode === "managed") { + patch.executionMode = "local"; + history.push({ accountId, type: "execution_mode.changed", at, actor: "system", before: "managed", after: "local" }); + } + return { + write: () => this.store.updateAccount({ accountId, expectedVersion: account.version, patch, updatedAt: at, history }), + result: undefined, + }; + }, + (err) => err, + ); } } -function seatNotFound(): NotFoundError { - return new NotFoundError("seat_not_found", "その席はありません"); -} - const TEAM_NAME_MAX_LENGTH = 64; const TEAM_NAME_MESSAGE = `1〜${TEAM_NAME_MAX_LENGTH} 字で入力してください`; +const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; + +export function validateSeatType(value: unknown): SeatType { + if (!isSeatType(value)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return value; +} + export function validateTeamName(name: unknown): string { if (typeof name !== "string") throw new InvalidRequestError(TEAM_NAME_MESSAGE); const length = [...name].length; diff --git a/factory/api/src/accounts/store.test.ts b/factory/api/src/accounts/store.test.ts index 9cea02796..abcf325e9 100644 --- a/factory/api/src/accounts/store.test.ts +++ b/factory/api/src/accounts/store.test.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import { mockClient } from "aws-sdk-client-mock"; import { DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, QueryCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb"; -import { ConditionFailedError, DynamoAccountStore } from "./store.ts"; +import { ConditionFailedError } from "./port.ts"; +import { DynamoAccountStore } from "./store.ts"; import type { AccountRecord, SeatRecord } from "./model.ts"; const ddb = mockClient(DynamoDBDocumentClient); @@ -53,7 +54,7 @@ describe("DynamoAccountStore(決定 8)", () => { it("アカウントの更新は版を条件にし、版を 1 増やす", async () => { ddb.on(TransactWriteCommand).resolves({}); - await store.updateAccount("a-1", 4, { plan: "free", executionMode: "local" }, account.updatedAt, []); + await store.updateAccount({ accountId: "a-1", expectedVersion: 4, patch: { plan: "free", executionMode: "local" }, updatedAt: account.updatedAt, history: [] }); const update = ddb.commandCalls(TransactWriteCommand)[0]!.args[0].input.TransactItems![0]!.Update!; expect(update.ConditionExpression).toBe("attribute_exists(#pk) AND #version = :expected"); expect(update.ExpressionAttributeValues).toMatchObject({ ":expected": 4, ":next": 5, ":plan": "free", ":mode": "local" }); diff --git a/factory/api/src/accounts/store.ts b/factory/api/src/accounts/store.ts index 30e626b18..85f43eb5e 100644 --- a/factory/api/src/accounts/store.ts +++ b/factory/api/src/accounts/store.ts @@ -8,55 +8,18 @@ import { type TransactWriteCommandInput, } from "@aws-sdk/lib-dynamodb"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { ConditionFailedError, type AccountPatch, type AccountStore, type AccountUpdate } from "./port.ts"; import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; -// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える -export class ConditionFailedError extends Error { - constructor() { - super("condition failed"); - this.name = "ConditionFailedError"; - } -} +// 鍵の頭。表の pk と GSI gsi1 の鍵は、利用者とアカウントの ID にこの頭を付ける +const USER_PREFIX = "USER#"; +const ACCOUNT_PREFIX = "ACCOUNT#"; +const userPk = (userId: string) => `${USER_PREFIX}${userId}`; +const accountPk = (accountId: string) => `${ACCOUNT_PREFIX}${accountId}`; -export interface AccountPatch { - plan?: AccountRecord["plan"]; - executionMode?: AccountRecord["executionMode"]; -} - -// アカウントの表への読み書き。どの書き込みも履歴の行を同じトランザクションで足す(I20) -export interface AccountStore { - getUser(userId: string): Promise; - // 利用者の行が無いときだけ、利用者・個人のアカウント・履歴を書く(I12) - createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]): Promise; - getAccount(accountId: string): Promise; - getSeat(accountId: string, userId: string): Promise; - listSeats(accountId: string): Promise; - // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 - // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う - listMemberships(userId: string): 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 と等しい)のときだけ変える - updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]): Promise; - // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ外す - removeSeat(expected: SeatRecord, history: HistoryRecord[]): Promise; - // version が expectedVersion と等しいときだけ変え、version を 1 増やす - updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ): Promise; -} - -const userKey = (userId: string) => ({ pk: `USER#${userId}`, sk: "USER" }); -const accountKey = (accountId: string) => ({ pk: `ACCOUNT#${accountId}`, sk: "ACCOUNT" }); -const seatKey = (accountId: string, userId: string) => ({ pk: `ACCOUNT#${accountId}`, sk: `SEAT#${userId}` }); +const userKey = (userId: string) => ({ pk: userPk(userId), sk: "USER" }); +const accountKey = (accountId: string) => ({ pk: accountPk(accountId), sk: "ACCOUNT" }); +const seatKey = (accountId: string, userId: string) => ({ pk: accountPk(accountId), sk: `SEAT#${userId}` }); type Item = Record; @@ -88,8 +51,8 @@ function accountItem(a: AccountRecord): Item { function seatItem(s: SeatRecord): Item { return { ...seatKey(s.accountId, s.userId), - gsi1pk: `USER#${s.userId}`, - gsi1sk: `ACCOUNT#${s.accountId}`, + gsi1pk: userPk(s.userId), + gsi1sk: accountPk(s.accountId), account_id: s.accountId, user_id: s.userId, seat_type: s.seatType, @@ -99,7 +62,7 @@ function seatItem(s: SeatRecord): Item { function historyItem(h: HistoryRecord): Item { return { - pk: `ACCOUNT#${h.accountId}`, + pk: accountPk(h.accountId), sk: `HIST#${h.at}#${randomUUID()}`, account_id: h.accountId, type: h.type, @@ -148,6 +111,14 @@ const SEAT_UNCHANGED = "attribute_exists(#pk) AND #seat_type = :expected_type AN const SEAT_NAMES = { "#pk": "pk", "#seat_type": "seat_type", "#added_at": "added_at" }; const seatExpected = (s: SeatRecord) => ({ ":expected_type": s.seatType, ":expected_added_at": s.addedAt }); +// AccountPatch の項目ごとの列の名前とプレースホルダ。SET には表の並びで足す +const PATCH_COLUMNS: { field: keyof AccountPatch; column: string; placeholder: string }[] = [ + { field: "plan", column: "plan", placeholder: ":plan" }, + { field: "executionMode", column: "execution_mode", placeholder: ":mode" }, +]; + +type TransactItem = NonNullable[number]; + export class DynamoAccountStore implements AccountStore { private readonly doc: DynamoDBDocumentClient; @@ -163,13 +134,15 @@ export class DynamoAccountStore implements AccountStore { return (out.Item as Item | undefined) ?? null; } - private put(item: Item, condition?: string): NonNullable[number] { + private put(item: Item, condition?: string): TransactItem { return { Put: { TableName: this.tableName, Item: item, ConditionExpression: condition } }; } - private async transact(items: NonNullable): Promise { + // どの書き込みも履歴の行を同じトランザクションで足す(I20)。並びは items → 履歴 → after + private async transact(items: TransactItem[], history: HistoryRecord[], after: TransactItem[] = []): Promise { + const all = [...items, ...history.map((h) => this.put(historyItem(h))), ...after]; try { - await this.doc.send(new TransactWriteCommand({ TransactItems: items })); + await this.doc.send(new TransactWriteCommand({ TransactItems: all })); } catch (err) { if ( err instanceof TransactionCanceledException && @@ -187,11 +160,7 @@ export class DynamoAccountStore implements AccountStore { } async createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]) { - await this.transact([ - this.put(userItem(user), NOT_EXISTS), - this.put(accountItem(account), NOT_EXISTS), - ...history.map((h) => this.put(historyItem(h))), - ]); + await this.transact([this.put(userItem(user), NOT_EXISTS), this.put(accountItem(account), NOT_EXISTS)], history); } async getAccount(accountId: string) { @@ -208,7 +177,7 @@ export class DynamoAccountStore implements AccountStore { const items = await this.queryAll({ TableName: this.tableName, KeyConditionExpression: "pk = :pk AND begins_with(sk, :seat)", - ExpressionAttributeValues: { ":pk": `ACCOUNT#${accountId}`, ":seat": "SEAT#" }, + ExpressionAttributeValues: { ":pk": accountPk(accountId), ":seat": "SEAT#" }, ConsistentRead: true, }); return items.map(toSeat); @@ -219,10 +188,10 @@ export class DynamoAccountStore implements AccountStore { TableName: this.tableName, IndexName: "gsi1", KeyConditionExpression: "gsi1pk = :u", - ExpressionAttributeValues: { ":u": `USER#${userId}` }, + ExpressionAttributeValues: { ":u": userPk(userId) }, }); // 射影(KEYS_ONLY など)によらず必ず載る表の鍵 pk(ACCOUNT#)から ID を取る - return items.map((i) => (i.pk as string).slice("ACCOUNT#".length)); + return items.map((i) => (i.pk as string).slice(ACCOUNT_PREFIX.length)); } private async queryAll(input: ConstructorParameters[0]): Promise { @@ -237,11 +206,10 @@ export class DynamoAccountStore implements AccountStore { } 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 + await this.transact( + [this.put(accountItem(account), NOT_EXISTS), this.put(seatItem(ownerSeat), NOT_EXISTS)], + history, + idempotency ? [ { Put: { @@ -253,8 +221,8 @@ export class DynamoAccountStore implements AccountStore { }, }, ] - : []), - ]); + : [], + ); } async getIdempotent(ref: IdempotencyRef, now: string) { @@ -263,7 +231,7 @@ export class DynamoAccountStore implements AccountStore { } async addSeat(seat: SeatRecord, history: HistoryRecord[]) { - await this.transact([this.put(seatItem(seat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h)))]); + await this.transact([this.put(seatItem(seat), NOT_EXISTS)], history); } async updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]) { @@ -278,8 +246,7 @@ export class DynamoAccountStore implements AccountStore { ExpressionAttributeValues: { ":t": seatType, ...seatExpected(expected) }, }, }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], history); } async removeSeat(expected: SeatRecord, history: HistoryRecord[]) { @@ -293,43 +260,34 @@ export class DynamoAccountStore implements AccountStore { ExpressionAttributeValues: seatExpected(expected), }, }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], history); } - async updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ) { + async updateAccount({ accountId, expectedVersion, patch, updatedAt, history }: AccountUpdate) { // 予約語と重ならないよう、列の名前は常に #名前 で書く const names: Record = { "#pk": "pk", "#version": "version", "#updated_at": "updated_at" }; const sets = ["#version = :next", "#updated_at = :at"]; const values: Record = { ":expected": expectedVersion, ":next": expectedVersion + 1, ":at": updatedAt }; - if (patch.plan !== undefined) { - names["#plan"] = "plan"; - sets.push("#plan = :plan"); - values[":plan"] = patch.plan; + for (const { field, column, placeholder } of PATCH_COLUMNS) { + if (patch[field] === undefined) continue; + names[`#${column}`] = column; + sets.push(`#${column} = ${placeholder}`); + values[placeholder] = patch[field]; } - if (patch.executionMode !== undefined) { - names["#execution_mode"] = "execution_mode"; - sets.push("#execution_mode = :mode"); - values[":mode"] = patch.executionMode; - } - await this.transact([ - { - Update: { - TableName: this.tableName, - Key: accountKey(accountId), - UpdateExpression: `SET ${sets.join(", ")}`, - ConditionExpression: "attribute_exists(#pk) AND #version = :expected", - ExpressionAttributeNames: names, - ExpressionAttributeValues: values, + await this.transact( + [ + { + Update: { + TableName: this.tableName, + Key: accountKey(accountId), + UpdateExpression: `SET ${sets.join(", ")}`, + ConditionExpression: "attribute_exists(#pk) AND #version = :expected", + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }, }, - }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], + history, + ); } } diff --git a/factory/api/src/app.ts b/factory/api/src/app.ts new file mode 100644 index 000000000..b0a1c7e48 --- /dev/null +++ b/factory/api/src/app.ts @@ -0,0 +1,82 @@ +// API の app を組む(ログ・CORS・操作の記録・認証と解決の中間処理・経路)。Lambda の handler は handlers/api.ts が配線する +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ApiError, AccountUnavailableError } from "./errors.ts"; +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 { recordOperations, requestIdOf } from "./audit/middleware.ts"; +import { registerMcp } from "./mcp/server.ts"; +import { registerFactory, type FactoryDeps } from "./routes/factory.ts"; + +export interface LogEntry { + // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) + request_id: string; + user_id: string | null; + method: string; + path: string; + status: number; +} + +export type Logger = (entry: LogEntry) => void; + +// ログは利用者の ID・経路・状態コードだけを出す。token とメールアドレスは出さない +export const jsonLogger: Logger = (entry) => console.log(JSON.stringify(entry)); + +// 画面のオリジン(FACTORY_WEB_URL のスキーム・ホスト・ポート)。無いか URL でなければ null で、どのオリジンも許さない +export function webOriginFromEnv(): string | null { + const url = process.env.FACTORY_WEB_URL; + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +// API の入口。#2171・#2186・#2174 は、返った app に経路を足し、同じ認証と解決の中間処理を共有する(決定 11) +export function createApiApp( + deps: () => AuthDeps, + log: Logger = jsonLogger, + webOrigin: () => string | null = webOriginFromEnv, + factory: (() => FactoryDeps) | null = null, +): 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, + status: c.res.status, + }); + }); + // 画面と API は別のオリジン。preflight(Authorization を持たない OPTIONS)を認証より先に返す + app.use( + "*", + cors({ + origin: (origin) => (origin === webOrigin() ? origin : null), + allowHeaders: ["Authorization", "Content-Type"], + 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(); + return c.json(known.body(), known.status); + }); + 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; +} diff --git a/factory/api/src/auth/deps.ts b/factory/api/src/auth/deps.ts new file mode 100644 index 000000000..854d4ee29 --- /dev/null +++ b/factory/api/src/auth/deps.ts @@ -0,0 +1,28 @@ +// API の認証と解決の依存を環境変数から作る +import { AccountService } from "../accounts/service.ts"; +import { DynamoAccountStore } from "../accounts/store.ts"; +import { DynamoAuditLog } from "../audit/dynamo-log.ts"; +import { StructuredLogAudit } from "../audit/structured-log.ts"; +import { DelegationService } from "../delegations/service.ts"; +import { DynamoDelegationStore } from "../delegations/store.ts"; +import { requireEnv } from "../env.ts"; +import type { AuthDeps } from "./resolve.ts"; +import { createAccessTokenVerifier } from "./verify.ts"; + +let cached: AuthDeps | null = null; + +// 環境変数から依存を作る。起動のたびに 1 回だけ作る +export function depsFromEnv(): AuthDeps { + cached ??= { + verifier: createAccessTokenVerifier({ + userPoolId: requireEnv("COGNITO_USER_POOL_ID"), + 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/auth/verify.ts b/factory/api/src/auth/verify.ts index 47ab11888..0822bcad5 100644 --- a/factory/api/src/auth/verify.ts +++ b/factory/api/src/auth/verify.ts @@ -14,6 +14,12 @@ const IDP_PREFIXES: ReadonlyArray<[string, Idp]> = [ ["GitHub_", "github"], ]; +// token の username の頭から IdP を決める。文字列でない・知らない頭なら undefined +function idpFromUsername(username: unknown): Idp | undefined { + const name = typeof username === "string" ? username : ""; + return IDP_PREFIXES.find(([prefix]) => name.startsWith(prefix))?.[1]; +} + export type AccessTokenVerifier = ReturnType; export function createAccessTokenVerifier(config: VerifierConfig) { @@ -36,8 +42,7 @@ export async function verifyAuthorization(verifier: AccessTokenVerifier, authori if (err instanceof JwtBaseError) throw new AuthError(); throw err; } - const username = typeof payload.username === "string" ? payload.username : ""; - const idp = IDP_PREFIXES.find(([prefix]) => username.startsWith(prefix))?.[1]; + const idp = idpFromUsername(payload.username); if (!idp || typeof payload.sub !== "string" || payload.sub === "") throw new AuthError(); return { userId: payload.sub, idp }; } diff --git a/factory/api/src/env.ts b/factory/api/src/env.ts new file mode 100644 index 000000000..ebbffeae3 --- /dev/null +++ b/factory/api/src/env.ts @@ -0,0 +1,6 @@ +// 環境変数を読む。無ければ起動の誤りとして投げる(API・受け口・GitHub の仲立ち・起票の代行・実行の制御が使う) +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`環境変数 ${name} がありません`); + return value; +} diff --git a/factory/api/src/github-oidc/app.ts b/factory/api/src/github-oidc/app.ts index 64f5681ea..20e8f2ab9 100644 --- a/factory/api/src/github-oidc/app.ts +++ b/factory/api/src/github-oidc/app.ts @@ -120,6 +120,12 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe return (await res.json()) as T; } + // GitHub の利用者。取れない・数の ID を持たなければ null + async function githubUser(accessToken: string): Promise { + const user = await githubJson("/user", accessToken); + return user && typeof user.id === "number" ? user : null; + } + async function exchangeGithubCode( clientId: string, clientSecret: string, @@ -212,8 +218,8 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe } const token = await exchangeGithubCode(clientId, clientSecret, code.gh_code); if (!token.access_token) return oauthError(c, "invalid_grant"); - const user = await githubJson("/user", token.access_token); - if (!user || typeof user.id !== "number") return oauthError(c, "invalid_grant"); + const user = await githubUser(token.access_token); + if (!user) return oauthError(c, "invalid_grant"); // sub は GitHub の数の利用者 ID。利用者が変えられるログイン名とメールを使わない const idToken = await issueIdToken(String(user.id), clientId, code.nonce); return c.json({ @@ -227,8 +233,8 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe app.on(["GET", "POST"], "/userinfo", async (c) => { const bearer = /^Bearer (.+)$/.exec(c.req.header("Authorization") ?? ""); if (!bearer) return oauthError(c, "invalid_token", 401); - const user = await githubJson("/user", bearer[1]!); - if (!user || typeof user.id !== "number") return oauthError(c, "invalid_token", 401); + const user = await githubUser(bearer[1]!); + if (!user) return oauthError(c, "invalid_token", 401); const emails = (await githubJson("/user/emails", bearer[1]!)) ?? []; const primary = emails.find((e) => e.primary && e.verified); return c.json({ diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index 6c7bdca01..d32c0a5f7 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; -import { createApiApp } from "./api.ts"; +import { createApiApp } from "../app.ts"; let api: TestApi; let keys = 0; diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index d6b306326..fa1fcf881 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -1,117 +1,7 @@ -import { Hono } from "hono"; +// API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler import { handle } from "hono/aws-lambda"; -import { cors } from "hono/cors"; -import { ApiError, AccountUnavailableError } from "../errors.ts"; -import { AccountService } from "../accounts/service.ts"; -import { DynamoAccountStore } from "../accounts/store.ts"; -import { createAccessTokenVerifier } from "../auth/verify.ts"; -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 { createApiApp, jsonLogger, webOriginFromEnv } from "../app.ts"; +import { depsFromEnv } from "../auth/deps.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; -export interface LogEntry { - // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) - request_id: string; - user_id: string | null; - method: string; - path: string; - status: number; -} - -export type Logger = (entry: LogEntry) => void; - -// ログは利用者の ID・経路・状態コードだけを出す。token とメールアドレスは出さない -export const jsonLogger: Logger = (entry) => console.log(JSON.stringify(entry)); - -// 画面のオリジン(FACTORY_WEB_URL のスキーム・ホスト・ポート)。無いか URL でなければ null で、どのオリジンも許さない -export function webOriginFromEnv(): string | null { - const url = process.env.FACTORY_WEB_URL; - if (!url) return null; - try { - return new URL(url).origin; - } catch { - return null; - } -} - -// API の入口。#2171・#2186・#2174 は、返った app に経路を足し、同じ認証と解決の中間処理を共有する(決定 11) -export function createApiApp( - deps: () => AuthDeps, - log: Logger = jsonLogger, - webOrigin: () => string | null = webOriginFromEnv, - factory: (() => FactoryDeps) | null = null, -): 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, - status: c.res.status, - }); - }); - // 画面と API は別のオリジン。preflight(Authorization を持たない OPTIONS)を認証より先に返す - app.use( - "*", - cors({ - origin: (origin) => (origin === webOrigin() ? origin : null), - allowHeaders: ["Authorization", "Content-Type"], - 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(); - return c.json(known.body(), known.status); - }); - 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; -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - -let cached: AuthDeps | null = null; - -// 環境変数から依存を作る。起動のたびに 1 回だけ作る -export function depsFromEnv(): AuthDeps { - cached ??= { - verifier: createAccessTokenVerifier({ - userPoolId: requireEnv("COGNITO_USER_POOL_ID"), - 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; -} - -// API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler export const handler = handle(createApiApp(depsFromEnv, jsonLogger, webOriginFromEnv, factoryDepsFromEnv)); diff --git a/factory/api/src/handlers/github-oidc.ts b/factory/api/src/handlers/github-oidc.ts index 10ee35b99..8ef28f435 100644 --- a/factory/api/src/handlers/github-oidc.ts +++ b/factory/api/src/handlers/github-oidc.ts @@ -1,14 +1,9 @@ import { handle } from "hono/aws-lambda"; import { Hono } from "hono"; +import { requireEnv } from "../env.ts"; import { createGithubOidcApp } from "../github-oidc/app.ts"; import { loadSigningKeys, type SigningKeys } from "../github-oidc/keys.ts"; -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - // 署名の鍵は起動のたびに 1 回だけ読む let keys: Promise | null = null; let app: Hono | null = null; diff --git a/factory/api/src/handlers/ingest.ts b/factory/api/src/handlers/ingest.ts index 59e3ac681..127d96a4f 100644 --- a/factory/api/src/handlers/ingest.ts +++ b/factory/api/src/handlers/ingest.ts @@ -5,24 +5,8 @@ import { handle } from "hono/aws-lambda"; import { createIngestApp, type IngestDeps } from "../progress/app.ts"; import { runJobTokenVerifierFromEnv } from "../progress/run-job-token.ts"; import { savedAnswersFromEnv } from "../progress/saved-answers.ts"; -import { DynamoProgressEventStore, type ProgressEventStore } from "../progress/store.ts"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - -let store: ProgressEventStore | null = null; - -// 進捗の記録の保存。受け口と appendControlEvent が同じものを使う -export function progressStoreFromEnv(): ProgressEventStore { - store ??= new DynamoProgressEventStore( - requireEnv("PROGRESS_EVENTS_TABLE_NAME"), - requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME"), - ); - return store; -} +import { requireEnv } from "../env.ts"; +import { progressStoreFromEnv } from "../progress/deps.ts"; let webhookSecret: Promise | null = null; diff --git a/factory/api/src/handlers/issue-requests.ts b/factory/api/src/handlers/issue-requests.ts index ac94df141..18ab1b4f5 100644 --- a/factory/api/src/handlers/issue-requests.ts +++ b/factory/api/src/handlers/issue-requests.ts @@ -4,18 +4,13 @@ import type { AttributeValue } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import type { DynamoDBBatchResponse, DynamoDBStreamEvent } from "aws-lambda"; import type { IssueRequestedEvent } from "@ndf-factory/contracts"; +import { requireEnv } from "../env.ts"; import { DynamoIssueLedger } from "../issue-requests/ledger.ts"; import { DEFAULT_ISSUE_REQUEST_LIMIT_PER_RUN, resolveIssueRequest, type IssueRequestDeps } from "../issue-requests/resolve.ts"; import { defaultFilingStyle } from "../issue-requests/style.ts"; import { appendIssueRequestResolvedTo } from "../progress/append.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; -import { progressStoreFromEnv } from "./ingest.ts"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} +import { progressStoreFromEnv } from "../progress/deps.ts"; // 上限は ISSUE_REQUEST_LIMIT_PER_RUN(1 以上の整数)。無い・形が違えば既定の 10 export function issueRequestLimit(env: Record): number { diff --git a/factory/api/src/index.ts b/factory/api/src/index.ts index 65ec4f550..1973cfe5e 100644 --- a/factory/api/src/index.ts +++ b/factory/api/src/index.ts @@ -1,14 +1,14 @@ // 他の子課題(#2171・#2186・#2174・#2189)へ公開する入口。使うのはここから export したものだけ import type { Hono } from "hono"; import { AccountService } from "./accounts/service.ts"; -import { depsFromEnv } from "./handlers/api.ts"; +import { depsFromEnv } from "./auth/deps.ts"; import { verifyAuthorization } from "./auth/verify.ts"; import type { AuthEnv } from "./auth/resolve.ts"; import type { AccountContext, Caller, Plan } from "./accounts/model.ts"; import type { SprintRun } from "./runs/model.ts"; import type { IssueRequestResolvedEvent, JobFinishedEvent } from "@ndf-factory/contracts"; import { appendControlEventTo, appendIssueRequestResolvedTo } from "./progress/append.ts"; -import { progressStoreFromEnv } from "./handlers/ingest.ts"; +import { progressStoreFromEnv } from "./progress/deps.ts"; import { runJobTokenVerifierFromEnv, type VerifiedRunJob } from "./progress/run-job-token.ts"; import { factoryDepsFromEnv } from "./runs/deps.ts"; @@ -16,7 +16,7 @@ export { getLimits, judgeLimits, LIMITS_TABLE } from "./accounts/limits.ts"; export type { CurrentUsage, Judgment, LimitReason, Limits } from "./accounts/limits.ts"; export type { AccountContext, Caller, ExecutionMode, Plan, SeatType } from "./accounts/model.ts"; export { ApiError, AuthError, ForbiddenError, PlanError } from "./errors.ts"; -export { createApiApp } from "./handlers/api.ts"; +export { createApiApp } from "./app.ts"; export { requireCaller, resolveFromRequest } from "./auth/resolve.ts"; export type { AuthEnv }; export type ApiApp = Hono; diff --git a/factory/api/src/progress/deps.ts b/factory/api/src/progress/deps.ts new file mode 100644 index 000000000..ce4a68436 --- /dev/null +++ b/factory/api/src/progress/deps.ts @@ -0,0 +1,14 @@ +// 進捗の記録の保存を環境変数から作る +import { requireEnv } from "../env.ts"; +import { DynamoProgressEventStore, type ProgressEventStore } from "./store.ts"; + +let store: ProgressEventStore | null = null; + +// 進捗の記録の保存。受け口と appendControlEvent が同じものを使う +export function progressStoreFromEnv(): ProgressEventStore { + store ??= new DynamoProgressEventStore( + requireEnv("PROGRESS_EVENTS_TABLE_NAME"), + requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME"), + ); + return store; +} diff --git a/factory/api/src/routes/accounts.ts b/factory/api/src/routes/accounts.ts index 30b594bb5..909785128 100644 --- a/factory/api/src/routes/accounts.ts +++ b/factory/api/src/routes/accounts.ts @@ -1,11 +1,10 @@ import type { Context, Hono } from "hono"; import { ForbiddenError, InvalidRequestError } from "../errors.ts"; -import { isExecutionMode, isSeatType, type AccountContext } from "../accounts/model.ts"; +import { isExecutionMode, type AccountContext } from "../accounts/model.ts"; +import { validateSeatType } from "../accounts/service.ts"; import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; import { registerOperation } from "./operation.ts"; -import { accountDetail, seatView } from "./views.ts"; - -const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; +import { accountDetailBody, seatView } from "./views.ts"; function userIdOf(raw: unknown): string { if (typeof raw !== "string" || raw === "" || raw.length > 128) throw new InvalidRequestError("利用者の ID の形ではありません"); @@ -25,8 +24,7 @@ export function registerAccounts(app: Hono): void { 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); + return accountDetailBody(accounts, ctx, account); }, }); @@ -38,25 +36,23 @@ export function registerAccounts(app: Hono): void { } 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); + return accountDetailBody(accounts, { ...ctx, executionMode: account.executionMode, plan: account.plan }, account); }, }); 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)); + const seatType = validateSeatType(e.seat_type); + return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, seatType)); }, }); 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)); + const seatType = validateSeatType(e.seat_type); + return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, seatType)); }, }); diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index eea325c60..b9654b4f2 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; -import { createApiApp, type LogEntry } from "../handlers/api.ts"; +import { createApiApp, type LogEntry } from "../app.ts"; import { RepositoryService } from "../repositories/service.ts"; import { memoryProgressReader } from "../progress/read.ts"; import { CANDIDATE_FALLBACK_NOTE, type CandidateModel } from "../runs/candidates.ts"; diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts index c463e8cbb..bace032ea 100644 --- a/factory/api/src/routes/sprint-views.test.ts +++ b/factory/api/src/routes/sprint-views.test.ts @@ -3,7 +3,7 @@ import type { ProgressEvent } from "@ndf-factory/contracts"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; -import { createApiApp } from "../handlers/api.ts"; +import { createApiApp } from "../app.ts"; import type { GitHubPort } from "../github/port.ts"; import { appendEvent } from "../progress/append.ts"; import { memoryProgressReader } from "../progress/read.ts"; diff --git a/factory/api/src/routes/teams.ts b/factory/api/src/routes/teams.ts index 77c0d275c..0094e2a9d 100644 --- a/factory/api/src/routes/teams.ts +++ b/factory/api/src/routes/teams.ts @@ -1,24 +1,21 @@ import type { Hono } from "hono"; -import { InvalidRequestError } from "../errors.ts"; -import { isSeatType } from "../accounts/model.ts"; -import { validateTeamName } from "../accounts/service.ts"; +import { validateSeatType, validateTeamName } from "../accounts/service.ts"; import type { AuthEnv } from "../auth/resolve.ts"; import { parseIdempotencyKey } from "../idempotency.ts"; import { registerOperation } from "./operation.ts"; -import { accountDetail } from "./views.ts"; +import { accountDetailBody } from "./views.ts"; export function registerTeams(app: Hono): void { // 同じ冪等の鍵の 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 seatType = validateSeatType(e.seat_type); 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 ctx = await accounts.createTeam(c.get("caller"), name, seatType, key); const account = await accounts.getAccount(ctx.accountId); - const seats = await accounts.listSeats(ctx); - return accountDetail(ctx, { name: account?.name ?? name }, seats); + return accountDetailBody(accounts, ctx, { name: account?.name ?? name }); }, }); } diff --git a/factory/api/src/routes/views.ts b/factory/api/src/routes/views.ts index e5c1364ab..7fada73a3 100644 --- a/factory/api/src/routes/views.ts +++ b/factory/api/src/routes/views.ts @@ -1,5 +1,6 @@ import { getLimits } from "../accounts/limits.ts"; import type { AccountContext, AccountRecord, SeatRecord, UserRecord } from "../accounts/model.ts"; +import type { AccountService } from "../accounts/service.ts"; // 応答の本文の形(factory/contracts/openapi/accounts.yaml) export function accountSummary(ctx: AccountContext, name: string | null) { @@ -30,3 +31,9 @@ export function accountDetail( seats: seats && seats.map(seatView), }; } + +// アカウントの詳細の本文。チームなら席を読んで載せ、個人なら seats を null にする +export async function accountDetailBody(accounts: AccountService, ctx: AccountContext, account: Pick) { + const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; + return accountDetail(ctx, account, seats); +} diff --git a/factory/api/src/runs/deps.ts b/factory/api/src/runs/deps.ts index 750f4118d..38c5e12a2 100644 --- a/factory/api/src/runs/deps.ts +++ b/factory/api/src/runs/deps.ts @@ -8,7 +8,8 @@ import { OrganizerControl } from "../organizer/control.ts"; import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { DynamoProgressReader } from "../progress/read.ts"; import type { RunJobSigningKey } from "../progress/run-job-token.ts"; -import { progressStoreFromEnv } from "../handlers/ingest.ts"; +import { progressStoreFromEnv } from "../progress/deps.ts"; +import { requireEnv } from "../env.ts"; import { RepositoryService } from "../repositories/service.ts"; import type { FactoryDeps } from "../routes/factory.ts"; import { bedrockCandidateModel } from "./candidates.ts"; @@ -19,12 +20,6 @@ import { AccountService } from "../accounts/service.ts"; import { DynamoAccountStore } from "../accounts/store.ts"; import { accountContextResolver, LoopControl } from "./loops.ts"; -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - const list = (name: string) => requireEnv(name).split(",").map((s) => s.trim()).filter(Boolean); // 秘密を 1 回だけ読む。読めなければ次の呼び出しで読み直す。値をログと応答に出さない diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts index 292ba14be..aa967de2a 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -4,7 +4,7 @@ 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 { createApiApp, type LogEntry } from "../app.ts"; import { createTokenKit, type TokenKit } from "./tokens.ts"; import type { FactoryDeps } from "../routes/factory.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx index 0249b33d6..5a8a71095 100644 --- a/factory/web/src/App.tsx +++ b/factory/web/src/App.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactElement } from "react"; import { Navigate, Route, Routes } from "react-router"; import { I18nextProvider } from "react-i18next"; -import { ServicesContext, type Services } from "./context.tsx"; +import { RequireLogin, ServicesContext, type Services } from "./context.tsx"; import { browserConnection, type ConnectionSource } from "./connection.ts"; import { bundledResources, createI18n, type TranslationResources } from "./i18n/index.ts"; import { LocaleSettingsContext, detectSettings, type LocaleSettings } from "./i18n/settings.ts"; @@ -50,7 +50,7 @@ export interface AppProps { } // ルーターは呼ぶ側が包む(main.tsx は BrowserRouter、テストは MemoryRouter) -// ログインの前の画面(/login・/callback)と地図に無い経路の転送は枠で包まない +// ログインの前の画面(/login・/callback)と地図に無い経路の転送は枠で包まない。枠の下の画面は token があるときだけ描く export function App({ services, settings, resources }: AppProps) { const full = useMemo(() => ({ ...services, connection: services.connection ?? browserConnection() }), [services]); const allResources = useMemo(() => ({ ...bundledResources, ...resources }), [resources]); @@ -64,7 +64,13 @@ export function App({ services, settings, resources }: AppProps) { {screens.filter((s) => s.roles.length === 0).map(route)} - }>{screens.filter((s) => s.roles.length > 0).map(route)} + + + + } + >{screens.filter((s) => s.roles.length > 0).map(route)} } /> diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx index f96393dc3..d5f06c7be 100644 --- a/factory/web/src/context.tsx +++ b/factory/web/src/context.tsx @@ -1,5 +1,5 @@ -import { createContext, useCallback, useContext, useSyncExternalStore } from "react"; -import { useNavigate } from "react-router"; +import { createContext, useCallback, useContext, useSyncExternalStore, type ReactNode } from "react"; +import { Navigate, useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ApiFailure, type Api } from "./api.ts"; @@ -54,3 +54,28 @@ export function useFailureHandler(): (err: unknown) => string | null { [navigate, t], ); } + +// ログインの後の画面の枠を包む。token が無ければ枠も画面も描かずにログインの画面へ移る +export function RequireLogin({ children }: { children: ReactNode }) { + const { auth } = useServices(); + if (auth.getToken() === null) return ; + return <>{children}; +} + +export type Attempt = (op: () => Promise, show: (message: string | null) => void) => Promise; + +// op を呼び、失敗したら画面の文を show へ渡す(401 ならログインの画面へ移って null)。attempt は投げない。 +// 描くたびに同じ関数を返す(useEffect の依存に入れても読み込みを繰り返さない) +export function useAttempt(): Attempt { + const onFailure = useFailureHandler(); + return useCallback( + async (op, show) => { + try { + await op(); + } catch (err) { + show(onFailure(err)); + } + }, + [onFailure], + ) as Attempt; +} diff --git a/factory/web/src/pages/AccountPage.tsx b/factory/web/src/pages/AccountPage.tsx index 79fe28586..068b27ca8 100644 --- a/factory/web/src/pages/AccountPage.tsx +++ b/factory/web/src/pages/AccountPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { Link, Navigate, useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import type { AccountDetail, Me } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useServices } from "../context.tsx"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; import { LoadingState } from "../ui/LoadingState.tsx"; @@ -13,27 +13,20 @@ import { ExecutionModeView, LimitsView } from "./parts.tsx"; export function AccountPage() { const { t } = useTranslation(); const { auth, api } = useServices(); - const onFailure = useFailureHandler(); + const attempt = useAttempt(); const navigate = useNavigate(); const [me, setMe] = useState(null); const [personal, setPersonal] = useState(null); const [error, setError] = useState(null); - const hasToken = auth.getToken() !== null; useEffect(() => { - if (!hasToken) return; - void (async () => { - try { - const m = await api.me(); - setMe(m); - setPersonal(await api.account(m.personal_account_id)); - } catch (err) { - setError(onFailure(err)); - } - })(); - }, [api, hasToken, onFailure]); + void attempt(async () => { + const m = await api.me(); + setMe(m); + setPersonal(await api.account(m.personal_account_id)); + }, setError); + }, [api, attempt]); - if (!hasToken) return ; if (error) return {error}; if (!me || !personal) return ; const teams = me.accounts.filter((a) => a.kind === "team"); diff --git a/factory/web/src/pages/BacklogPage.tsx b/factory/web/src/pages/BacklogPage.tsx index 9be25bb09..e365a91a4 100644 --- a/factory/web/src/pages/BacklogPage.tsx +++ b/factory/web/src/pages/BacklogPage.tsx @@ -1,5 +1,5 @@ import { useCallback, type ReactNode } from "react"; -import { Link, Navigate, useParams } from "react-router"; +import { Link, useParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { Backlog } from "../api.ts"; import { useServices } from "../context.tsx"; @@ -22,13 +22,11 @@ export function BacklogPage() { const { t } = useTranslation(); const format = useFormat(); const { accountId = "", repositoryId = "" } = useParams(); - const { auth, api } = useServices(); - const hasToken = auth.getToken() !== null; + const { api } = useServices(); const read = useCallback(async () => (await api.backlogs(accountId, [Number(repositoryId)]))[0] ?? null, [api, accountId, repositoryId]); // 1 回の読み出しが GitHub の全ページと実行ごとの事象を読むため、間隔では読み直さない(開いたときと「もう一度読む」だけ) - const { data: backlog, error, reload } = usePolled(read, hasToken, { poll: false }); + const { data: backlog, error, reload } = usePolled(read, { poll: false }); - if (!hasToken) return ; const header = (title: string, actions?: ReactNode) => ( <> diff --git a/factory/web/src/pages/DelegationsPage.tsx b/factory/web/src/pages/DelegationsPage.tsx index 64e6757a8..60d39f5c8 100644 --- a/factory/web/src/pages/DelegationsPage.tsx +++ b/factory/web/src/pages/DelegationsPage.tsx @@ -1,8 +1,7 @@ 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 { useAttempt, useConnection, useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; @@ -27,9 +26,9 @@ const STATE_KEY = { active: "delegations.stateActive", expired: "delegations.sta export function DelegationsPage() { const { t } = useTranslation(); const format = useFormat(); - const { auth, api, apiUrl } = useServices(); + const { api, apiUrl } = useServices(); const { online } = useConnection(); - const onFailure = useFailureHandler(); + const attempt = useAttempt(); const [rows, setRows] = useState(null); const [loadError, setLoadError] = useState(null); const [name, setName] = useState(""); @@ -38,28 +37,21 @@ export function DelegationsPage() { 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]); + await attempt(async () => setRows(await api.delegations()), setLoadError); + }, [api, attempt]); useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); - - if (!hasToken) return ; + void load(); + }, [load]); const issue = async () => { setMessage(null); if (name.trim() === "") return setMessage(t("delegations.needClientName")); setBusy(true); - try { + await attempt(async () => { const { delegation, credential } = await api.createDelegation({ client_name: name, scope, expires_in_seconds: expires }); setIssued(credential); setName(""); @@ -67,24 +59,18 @@ export function DelegationsPage() { // 黙って受けると、使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる 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); - } + }, setMessage); + setBusy(false); }; const revoke = async (credentialId: string) => { setMessage(null); setBusy(true); - try { + await attempt(async () => { 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); - } + }, setMessage); + setBusy(false); }; const example = issued && JSON.stringify({ url: `${apiUrl ?? ""}/mcp`, headers: { Authorization: `Bearer ${issued}` } }, null, 2); diff --git a/factory/web/src/pages/LoopDetailPage.tsx b/factory/web/src/pages/LoopDetailPage.tsx index 7d9fca146..19577df27 100644 --- a/factory/web/src/pages/LoopDetailPage.tsx +++ b/factory/web/src/pages/LoopDetailPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from "react"; -import { Link, Navigate, useParams } from "react-router"; +import { Link, useParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { LoopDecision, LoopDetail, LoopPending } from "../api.ts"; -import { useConnection, useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useConnection, useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; import { Alert } from "../ui/Alert.tsx"; import { ApprovalActions } from "../ui/ApprovalActions.tsx"; @@ -28,45 +28,37 @@ export function LoopDetailPage() { const view = useLoopView(); const sprintState = useSprintState(); const { accountId = "", loopId = "" } = useParams(); - const { auth, api } = useServices(); + const { api } = useServices(); const { online } = useConnection(); - const onFailure = useFailureHandler(); + const attempt = useAttempt(); const [loaded, setLoaded] = useState(null); const [error, setError] = useState(null); const [budget, setBudget] = useState(budgetText()); const [budgetError, setBudgetError] = useState(null); const [message, setMessage] = useState(null); const [busy, setBusy] = useState(false); - const hasToken = auth.getToken() !== null; const load = useCallback(async () => { setError(null); - try { + await attempt(async () => { const detail = (await api.loopDetails(accountId, [loopId]))[0] ?? null; setLoaded({ detail }); if (detail) setBudget(budgetText(detail.loop.budget)); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, loopId, onFailure]); + }, setError); + }, [api, accountId, loopId, attempt]); useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); - - if (!hasToken) return ; + void load(); + }, [load]); const act = async (fn: () => Promise) => { setMessage(null); setBusy(true); - try { + await attempt(async () => { await fn(); await load(); - } catch (err) { - setMessage(onFailure(err)); - } finally { - setBusy(false); - } + }, setMessage); + setBusy(false); }; // ループの行はリポジトリの ID を持たないため、戻り先はリポジトリの画面にする diff --git a/factory/web/src/pages/LoopsPage.tsx b/factory/web/src/pages/LoopsPage.tsx index 0d64e2b9e..b623ad2e4 100644 --- a/factory/web/src/pages/LoopsPage.tsx +++ b/factory/web/src/pages/LoopsPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from "react"; -import { Link, Navigate, useNavigate, useParams } from "react-router"; +import { Link, useNavigate, useParams } from "react-router"; import { useTranslation } from "react-i18next"; import { ApiFailure, type LoopRow } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useFailureHandler, useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; @@ -27,8 +27,9 @@ export function LoopsPage() { const navigate = useNavigate(); const { accountId = "", repositoryId = "" } = useParams(); const repoId = Number(repositoryId); - const { auth, api } = useServices(); + const { api } = useServices(); const onFailure = useFailureHandler(); + const attempt = useAttempt(); const [loops, setLoops] = useState(null); const [error, setError] = useState(null); const [name, setName] = useState(""); @@ -36,23 +37,16 @@ export function LoopsPage() { const [budget, setBudget] = useState(budgetText()); const [fieldErrors, setFieldErrors] = useState<{ name?: string; milestones?: string; budget?: string }>({}); const [message, setMessage] = useState(null); - const hasToken = auth.getToken() !== null; const load = useCallback(async () => { setError(null); setLoops(null); - try { - setLoops((await api.loops(accountId, repoId)).loops); - } catch (err) { - setError(onFailure(err)); - } - }, [api, accountId, repoId, onFailure]); + await attempt(async () => setLoops((await api.loops(accountId, repoId)).loops), setError); + }, [api, accountId, repoId, attempt]); useEffect(() => { - if (hasToken) void load(); - }, [hasToken, load]); - - if (!hasToken) return ; + void load(); + }, [load]); const start = async () => { setMessage(null); diff --git a/factory/web/src/pages/ModelCredentialPage.tsx b/factory/web/src/pages/ModelCredentialPage.tsx index 953bd42bc..364a3578c 100644 --- a/factory/web/src/pages/ModelCredentialPage.tsx +++ b/factory/web/src/pages/ModelCredentialPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import { Navigate, useParams } from "react-router"; +import { useParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { ModelCredential } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useServices } from "../context.tsx"; import { useFormat } from "../i18n/index.ts"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; @@ -19,8 +19,8 @@ export function ModelCredentialPage() { const { t } = useTranslation(); const format = useFormat(); const { accountId = "" } = useParams(); - const { auth, api } = useServices(); - const onFailure = useFailureHandler(); + const { api } = useServices(); + const attempt = useAttempt(); const [current, setCurrent] = useState(null); const [kind, setKind] = useState<"anthropic_api_key" | "aws_role">("anthropic_api_key"); const [apiKey, setApiKey] = useState(""); @@ -28,20 +28,11 @@ export function ModelCredentialPage() { const [region, setRegion] = useState(""); const [error, setError] = useState(null); const [message, setMessage] = useState(null); - const hasToken = auth.getToken() !== null; useEffect(() => { - if (!hasToken) return; - void (async () => { - try { - setCurrent(await api.modelCredential(accountId)); - } catch (err) { - setError(onFailure(err)); - } - })(); - }, [api, accountId, hasToken, onFailure]); + void attempt(async () => setCurrent(await api.modelCredential(accountId)), setError); + }, [api, accountId, attempt]); - if (!hasToken) return ; if (error) return {error}; if (!current) return ; @@ -50,14 +41,12 @@ export function ModelCredentialPage() { if (kind === "anthropic_api_key" && (apiKey.length < 1 || apiKey.length > 512)) return setMessage(t("modelCredential.needApiKey")); if (kind === "aws_role" && (!ROLE_ARN.test(roleArn) || roleArn.length > 2048)) return setMessage(t("modelCredential.badRoleArn")); if (kind === "aws_role" && (!REGION.test(region) || region.length > 32)) return setMessage(t("modelCredential.badRegion")); - try { + await attempt(async () => { setCurrent( await api.saveModelCredential(accountId, kind === "anthropic_api_key" ? { kind, api_key: apiKey } : { kind, role_arn: roleArn, region }), ); setApiKey(""); - } catch (err) { - setMessage(onFailure(err)); - } + }, setMessage); }; return ( @@ -98,14 +87,12 @@ export function ModelCredentialPage() { {current.registered && ( diff --git a/factory/web/src/pages/NewSprintPage.tsx b/factory/web/src/pages/NewSprintPage.tsx index cd992b479..338a97a68 100644 --- a/factory/web/src/pages/NewSprintPage.tsx +++ b/factory/web/src/pages/NewSprintPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState } from "react"; -import { Navigate, useNavigate, useParams } from "react-router"; +import { useNavigate, useParams } from "react-router"; import { useTranslation } from "react-i18next"; import { ApiFailure, type IssueCandidate } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useFailureHandler, useServices } from "../context.tsx"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; import { LoadingState } from "../ui/LoadingState.tsx"; @@ -23,8 +23,9 @@ export function NewSprintPage() { const { t } = useTranslation(); const { accountId = "", repositoryId = "" } = useParams(); const repoId = Number(repositoryId); - const { auth, api } = useServices(); + const { api } = useServices(); const onFailure = useFailureHandler(); + const attempt = useAttempt(); const navigate = useNavigate(); const [candidates, setCandidates] = useState(null); const [selected, setSelected] = useState([]); @@ -32,7 +33,6 @@ export function NewSprintPage() { const [extra, setExtra] = useState(""); const [error, setError] = useState(null); const [message, setMessage] = useState(null); - const hasToken = auth.getToken() !== null; const choose = useCallback( async (exclude: number[]) => { @@ -45,11 +45,9 @@ export function NewSprintPage() { ); useEffect(() => { - if (!hasToken) return; - choose([]).catch((err: unknown) => setError(onFailure(err))); - }, [choose, hasToken, onFailure]); + void attempt(() => choose([]), setError); + }, [attempt, choose]); - if (!hasToken) return ; if (error) return {error}; if (!candidates) return ; const titles = new Map(candidates.map((c) => [c.number, c])); @@ -112,7 +110,7 @@ export function NewSprintPage() {
diff --git a/factory/web/src/pages/NewTeamPage.tsx b/factory/web/src/pages/NewTeamPage.tsx index 2f01f9480..f9819fbc4 100644 --- a/factory/web/src/pages/NewTeamPage.tsx +++ b/factory/web/src/pages/NewTeamPage.tsx @@ -1,24 +1,23 @@ import { useState } from "react"; -import { Navigate, useNavigate } from "react-router"; +import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import type { SeatType } from "../api.ts"; -import { useFailureHandler, useServices } from "../context.tsx"; +import { useAttempt, useServices } from "../context.tsx"; import { Alert } from "../ui/Alert.tsx"; import { Button } from "../ui/Button.tsx"; import { PageHeader } from "../ui/PageHeader.tsx"; import { SelectField } from "../ui/SelectField.tsx"; import { TextField } from "../ui/TextField.tsx"; -import { SEAT_TYPES } from "./parts.tsx"; +import { SeatTypeOptions } from "./parts.tsx"; export function NewTeamPage() { const { t } = useTranslation(); - const { auth, api } = useServices(); - const onFailure = useFailureHandler(); + const { api } = useServices(); + const attempt = useAttempt(); const navigate = useNavigate(); const [name, setName] = useState(""); const [seatType, setSeatType] = useState("standard"); const [message, setMessage] = useState(null); - if (auth.getToken() === null) return ; return ( <> @@ -32,21 +31,15 @@ export function NewTeamPage() { setMessage(t("newTeam.nameLength")); return; } - try { + await attempt(async () => { const team = await api.createTeam(name, seatType); navigate(`/accounts/${team.account_id}`); - } catch (err) { - setMessage(onFailure(err)); - } + }, setMessage); }} > setName(e.target.value)} /> setSeatType(e.target.value as SeatType)}> - {SEAT_TYPES.map((s) => ( - - ))} +