From d3f1c57cebe7e90bef8a6054ef4dac028998e916 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 08:38:53 +0000 Subject: [PATCH 01/39] =?UTF-8?q?Docs:=20=E3=83=97=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E5=8F=82=E7=85=A7=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=AE?= =?UTF-8?q?=E7=9B=B4=E5=89=8D=E3=81=AB=E8=A7=A3=E6=B1=BA=E3=81=97=E3=81=A6?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=81=99=E3=82=8B=E6=9F=B1=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88#2344=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 課題の本文の写しを issues/ に置き、要求で使う語(プランの参照・解決の記録・承認時の値)を用語集へ足した。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 + docs/glossary/glossary.json | 18 +++ issues/issue-2344-requirements.md | 206 ++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 issues/issue-2344-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 2f6071b3f..4e471982a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -383,6 +383,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | +| プランの参照 | — | プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・`.ndf/` の設定の 1 つのキー・上限の宣言の 1 つの方針など)。流す直前に実行の核が値へ解決する(#2344) | — | — | — | +| 解決の記録 | — | 1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344) | — | — | — | +| 承認時の値 | — | 承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index f34e1760d..ec312ce23 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5303,6 +5303,24 @@ "context": "ndf-workflow", "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "source": "docs/specifications/ndf-context-restart-policy.md" + }, + { + "term": "プランの参照", + "context": "ndf-workflow", + "meaning": "プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・`.ndf/` の設定の 1 つのキー・上限の宣言の 1 つの方針など)。流す直前に実行の核が値へ解決する(#2344)", + "source": "" + }, + { + "term": "解決の記録", + "context": "ndf-workflow", + "meaning": "1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344)", + "source": "" + }, + { + "term": "承認時の値", + "context": "ndf-workflow", + "meaning": "承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344)", + "source": "" } ] } diff --git a/issues/issue-2344-requirements.md b/issues/issue-2344-requirements.md new file mode 100644 index 000000000..f6347953d --- /dev/null +++ b/issues/issue-2344-requirements.md @@ -0,0 +1,206 @@ +# #2344: プランは値でなく参照を持ち、実行の直前に解決して照合する + +正は課題の本文(#2344)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +大方針 #2335(異常系・ログ・回復とデータ・入出力の形を 1 つの実行の核へ集める)の柱の 1 本(柱の記号 P5「実行時点の解決」)。子の課題は、この柱の仕組みがそろえば解消する可能性があるものである。 + +## 依頼(原文) + +この課題の題と本文は、起票者(課題を書いた AI)が 2026-11-01 に大方針 #2335 の組み直しの記録として書いた文で、利用者の指示の原文ではない。この柱の元になった利用者の指示は、#2335 の「依頼(原文)」が引用した次の文である。 + +> 異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない + +> 内部データ構造と出力のフォーマットの不統一の影響も大きそう + +> NDF は Skill の Script 化から発展してきたため、内部データ構造や入出力契約がほぼ整理されていない + +> 出力だけでなく入力もそろえるべき + +(書き手: 利用者、日付: 2026-11-01。#2335 の本文 https://github.com/devbasex/ai-plugins/issues/2335 から引用した) + +起票時の本文(書き手: 起票者、日付: 2026-11-01): + +> プランは版・記録の PR・宣言・予算・触るファイル・承認時の先端などを参照で持ち、実行の核がステップの直前に解決して記録する。解決できない・承認した値と食い違うときは流さずに止まる。m26-3 の #2230(記録の PR)と #2164(版数)を最初の事例とし、その形を全体へ広げる。 + +> 1. プランは版・記録の PR・宣言の値を参照で持ち、核が実行の直前に解決する。解決できなければ流さずに止まる +> 2. 承認ゲートで見た値(先端・版)と実行時の値の食い違いを核が照合し、食い違えば止まって人へ戻す +> 3. 子の課題それぞれを、閉じた(解消を確かめた)か残した(理由つき)かに分けて本文に記録する + +> 子の課題は 1 件ずつ直さない。柱の仕組みを入れた後に、子の課題の再現の手順を 1 回ずつ通し、解消したものを理由を添えて閉じる。設計は #2335 の設計(マイルストーン「28 根本解決 1: 設計と契約の棚卸し」)の後に、この柱ごとに行う。 + +子の課題の結び付け(書き手: 起票者、日付: 2026-11-01。課題のコメント): + +> 子として #2349・#2354 を結び付けました(スプリント lroot-2 の棚卸し)。 + +この要求の整理の指示(書き手: AI の起動指示、日付: 2026-11-04): + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +**解釈**: 利用者が求めているのは、プランを作った時点の値が流す時点で古くなって止まる型の不具合(大方針の原因の型 E「解決の時点」。版・記録の PR・宣言・予算)を、1 件ずつではなく仕組みで無くすことである。今は #2164 の版の差し込み(`{release_version}`)と #2230 の記録の PR の差し込み(`{queue_record}`)が、キューの中でそれぞれ別の手当てとして解決している。この柱は、その 2 つを実行の核の参照の部品(#2335 の設計の `lib/refs.py`)へ寄せ、ほかの値にも同じ形を当てる。 + +## 起票者の推定 + +題・本文のうち、上の利用者の原文に無く、起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。S6〜S8 はこの要求の整理で足した。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | プランは版・記録の PR・宣言の値を、値でなく参照で持つ | 起票時の本文の受け入れ条件 1・#2335 の受け入れ条件 6 | 受け入れ条件 1 | +| S2 | 実行の核がステップの直前(持ち主の記録を取った後)に参照を解決し、解決した値を記録する。解決できなければ流さずに止まる | 起票時の本文・#2335 の設計の不変条件 I14・ドメインイベント E5 | 受け入れ条件 2・3 | +| S3 | 承認ゲートで見た値(先端・版)と実行時の値の食い違いを核が照合し、食い違えば止まって人へ戻す | 起票時の本文の受け入れ条件 2 | 受け入れ条件 4 | +| S4 | 予算も参照で持つ | 起票時の本文「何をするか」・#2335 の原因の型 E の説明 | 受け入れ条件 1。値は柱 P4(#2343)の上限の宣言から引く(前提 4) | +| S5 | 触るファイルも参照で持つ | 起票時の本文「何をするか」 | 含まない。触るファイルは、プランを書いた側が宣言する「触る予定の範囲」で、プランの外の状態が変わって古くなる値ではない。触るファイルが絡む課題(#1811・#2037・#2165)は重なりの判定の粒度の問題で、柱 P3(#2342)が残す見込みとしている | +| S6 | 解決した値はプランのファイルへ書き戻さず、実行の記録にだけ残す(今のキューは差し込みを埋めたプランでファイルを上書きする) | #2349 の「完了の後にプランの JSON が変わったら流し直す」事象。プランを上書きすると、プランが変わったのが人の編集か解決かを区別できない | 受け入れ条件 2。誤ると、解決の記録と流し直しの判定(受け入れ条件 7)を作り直すことになるため、問わずにこの案を選んだ | +| S7 | 実行条件(プランの `実行の条件`)も、ステップの直前に評価する値として参照と同じ段で扱い、`--from` で始めても先に評価する | #2349 の「直したいこと」1 | 受け入れ条件 6 | +| S8 | 開発ループ(`sprint-state.py loop sprint`)が組むスプリントも、版を参照で持つ | #2354 の「起票者の推定」1 点目 | 受け入れ条件 8 | + +## 目的 + +- プランを作ってから流すまでの間に版・記録の PR・宣言・予算が変わっても、プランを作り直さずに流せる状態にする(今は #2164 の 2 回の再発のように、conductor がプランを作り直すか版を書き換えた) +- 参照を解決できないとき・承認ゲートで人が見た値と食い違うときに、推定の値や古い値で黙って進まず、理由を添えて止まる状態にする +- 参照を解決する手当てを 1 か所にまとめ、新しい種類の値を足しても同じ規則で解決・記録・照合される状態にする(今は版・記録の PR・前のプランの結果が、キューの中で別々の関数として解決している) + +## 前提 + +- 前提 1: この柱の設計と実装は、#2335 の設計(`issues/issue-2335-design.md`・`issues/issue-2335-design-decisions.md`・`issues/issue-2335-design-pillars.md`)を正とする。参照の部品(`lib/refs.py`)・不変条件 I14・ドメインイベント E5 はそれに従う +- 前提 2: この柱の実装は、柱 P6(#2339 データの契約。プランの型)・P8(#2341 入力の契約)・P3(#2342 持ち主の記録)のマージの後に始める(`issues/issue-2335-design-pillars.md` の順序)。2026-11-04 の時点で 3 本とも未完了で、`plugins/ndf/scripts/lib/` に `run_core.py`・`refs.py`・`lease.py` は無い(`ls plugins/ndf/scripts/lib/` の結果に 3 つの名前が出ないことで確かめた)。設計は 3 本の設計文書を正として先に書いてよい +- 前提 3: 寄せる元の実装は次の 4 つである。どれも 2026-11-04 の develop にあり、キューがステージを流す直前に解決している + - 版の差し込み(`{release_version}`・`{release_base}`)と版の記録(`supervise_lib/queue_fill.py` の `fill_release_version`、`lib/release_version.py`) + - 記録の PR の差し込み(`{queue_record}`。`supervise_lib/queue_fill.py` の `_fill_record`) + - 前のプランの結果の差し込み(`{queue_pr:<名>}`・`{queue_state:<名>}`・`{queue_prs:<名>}`・`{queue_approved:<名>}`・`QUEUE_PRS`) + - 承認したコミットと先端の比較(`lib/approved_commit.py` の `compare_approved`。#815) +- 前提 4: 予算の参照は、上限の宣言(柱 P4 #2343 の `plugins/ndf/contracts/limits.json`)の方針の名前を指す。値はこの柱では決めず、上限の部品が返す。今の予算はプランを作る時点に秒へ直して `timeout` に書き込んでいる(`supervise_lib/templates.py` の `_inspect_steps`) +- 前提 5: 承認したコミットとの照合は、今の `compare_approved` と同じく「承認したコミットの後に、許したもの(この版の配布の Pull Request)だけが入っている」なら食い違いとみなさない +- 前提 6: 参照を持たない旧い形のプラン(この柱より前の版の NDF が作り、値を書き込んだプラン)は、書き込まれた値をそのまま使って流す。実行の記録にはその値の出所を「プランの値」と書く(#2335 の移行性。旧い版が書いた状態ファイルを読む受け入れ条件 10b と同じ扱い) + +## 対象範囲 + +含む: +- 参照の部品と、実行の核の参照の段(E4〜E7) +- 前提 3 の 4 つの実装を参照の部品へ寄せること(キューに残る差し込みの関数を消す) +- 宣言の値(`.ndf/` の設定)と予算を、プランを作る時点の値でなく参照で持たせること。対象のキーは設計が契約の一覧(`plugins/ndf/contracts/settings/`)から一覧にする +- 実行条件を、`--from` で始めるときも含めてステップの直前に評価すること(#2349 の 1 つ目) +- 完了したプランの流し直しの判定を、解決の記録とプランの中身の照合で行うこと(#2349 の 2 つ目) +- 開発ループが組むスプリントの版を参照にすること(#2354) +- 子の課題 4 件の再現の手順を通し、閉じるか残すかを本文に記録すること + +含まない: +- 子の課題を 1 件ずつ直す Pull Request(即時修正の 4 条件を満たすものと本番を止めているものは除く) +- 触るファイルを参照にすること(起票者の推定 S5) +- 差分の無い実装のプランで `pr` が終了コード 1 になること(#2349 の 3 つ目)。値の解決の時点の問題でなく、結果の契約(柱 P1 #2337)の問題である(受け入れ条件 12 で確かめて残す) +- 上限と再試行の値そのもの(柱 P4 #2343)と、持ち主の記録の取り方(柱 P3 #2342) +- 版の規則(次に空いている版の決め方・決め直しの条件)を変えること。#2164 の規則をそのまま使う +- `factory/` のプラン + +## ドメインイベント + +1 回のプランの実行のうち、参照の解決と照合に関わる部分を時間の順に並べる。右端の列は #2335 のドメインイベントの番号である。 + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | #2335 | +| --- | --- | --- | --- | --- | --- | +| E1 | プランを参照つきで作った | `supervise.py new sprint` / `new close`・`sprint-state.py loop sprint` | 参照の形がプランの型に合わない → 作らずに `stopped`(入力違い、終了コード 2) | — | — | +| E2 | 承認ゲートで人が見た値(承認したコミット・版)を承認の証跡に記録した | 承認ゲート 1・2 の承認 | 記録できない → 承認を成立させない(今の `approved_commit.record` と同じ) | E1 | — | +| E3 | ステップの起動で持ち主の記録を取った | キュー・`supervise.py run`・再開の入口 | 柱 P3 の扱い | E2(承認ゲートの後のステップ) | E4 | +| E4 | 実行条件を評価した | E3 | 条件のコマンドが判定できない → `stopped`。飛ばす条件に当たる → 参照を解決せずに完了 | E3。`--from` の有無によらない | E5 | +| E5 | 参照を解決した | E4 で流すと決まった | 解決できない(記録の PR が無い・版を読めない・宣言のキーが無い・前のプランの結果が無い)→ ステップを流さず `stopped` | E3(並行する実行と同じ版を取り合わない) | E5 | +| E6 | 解決した値を承認ゲートで見た値と照らした | E5 で、E2 の記録がある参照 | 食い違う → `gate`(人へ戻す) | E2・E5 | E5 | +| E7 | 解決した値と出所を実行の記録へ書いた | E5・E6 | 書けない → `stopped`(#2335 の I3) | E6 | E5 | +| E8 | ステップを流した | E7 | 柱 P1 の扱い | E7 | E7 | +| E9 | 同じプランを流し直し・再開した | 再開の入口・キューの `--resume` | プランの中身が前の実行の記録と違う、かつ前の実行が完了 → 流さずに `gate` | 前の実行の記録がある | E13 | + +洗い出しで移したもの: E1 で参照の形を決める型はプランの型(柱 P6)に置いた(前提 2)。E5 の「流し直しで前に解決した値を使い続けるか」は受け入れ条件 5 に置いた。E6 の「食い違いとみなさない差」は前提 5 に置いた。E9 の「プランの中身の同一性を何で測るか」は未決に置いた。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| プランの参照 | プランが値の代わりに持つ、値の取り先の名前(例: 次に空いている開発版・この課題のリリース記録を置いた PR・`.ndf/pace.json` の 1 つのキー・上限の宣言の 1 つの方針)。流す直前に実行の核が値へ解決する。この文書で単に「参照」と書くときはこの意味である | +| 解決の記録 | 1 回の実行が解決した参照ごとの値と出所。実行の記録(#2335 の P2)の行として残る | +| 承認時の値 | 承認ゲートで人(か MVV 判定)が承認資料で見た値。承認したコミットと版を含む | +| 版の差し込み | 配布のプランが具体的な版の代わりに持つ `{release_version}`・`{release_base}`。この柱で参照の 1 種類になる | +| 版の記録 | 配布のプランの状態ディレクトリの `version.json`。この柱で解決の記録の 1 種類になる | +| 記録の PR | リリース記録(`## 配布の記録`)を置いた Pull Request | +| 承認したコミット | 承認ゲート 2 の承認資料が対象にした、ベースブランチの先端のコミットの SHA | +| 実行条件 | プランを流す前に打つコマンド(プランの `実行の条件`)。`skip_code` を返せば完了とする | +| 実行の核 | 入口・ステップが必ず通る共通の実行の仕組み(#2335 の `lib/run_core.py`) | + +## 受け入れ条件 + +- [ ] 1. `supervise.py new sprint`(版を明示しない)・`new close`・`sprint-state.py loop sprint` が組むプランのファイルは、版数・記録の PR の番号・宣言から読んだ値(設計が一覧にするキー)・予算の秒を値として持たず、参照で持つ。組んだプランのファイルを読むテストで確かめる +- [ ] 2. 参照は、ステップの直前(持ち主の記録を取った後)に実行の核が解決し、参照ごとの値と出所が実行の記録に残る。解決でプランのファイルは書き換わらない(流す前と後でファイルの sha256 が同じ) +- [ ] 3. 参照を解決できないとき(記録の PR が見つからない・origin の版かタグを読めない・宣言のキーが無い・前のプランの完了した結果が無い)は、ステップを流さず `stopped` で返り、結果 JSON に参照の名前と解決できなかった理由が出る。推定の値・空・`0`・`unknown` で埋めて流さない +- [ ] 4. 承認ゲート 2 の後に、承認したコミットの後へ許したもの以外のコミットが入った、または承認資料の版と解決した版が違うとき、本番のステップを流さず `gate` で返り、結果 JSON に承認時の値と実行時の値が並ぶ。承認したコミットの後に許したものだけが入ったときは流れる(前提 5) +- [ ] 5. 同じプランの同じステップを流し直すと、前の実行で解決した版を使う。ただし #2164 の版の決め直し(自分の配布のマージより前に別のスプリントがその版を出した)に当たるときは、#2164 の規則で決め直し、決め直した前の版と理由が解決の記録に残る +- [ ] 6. 実行条件を満たして飛ばすはずのプランを `--from <プラン>=<ステップ>` で始めても、そのプランは飛ばされ、結果に飛ばした旨が出る(#2349 の再現の手順で確かめる) +- [ ] 7. 完了したプランを含む done のファイルを `--resume` しても、完了したプランは流れない。完了の後にプランのファイルが変わっていたら、流さずに `gate` で返り、変わったことと流し直す手段を結果に出す(#2349 の再現の手順で確かめる) +- [ ] 8. `sprint-state.py loop sprint` は `--dev` を省いて起動でき、省いたとき組むスプリントのプランは版を参照で持つ。`--dev` を渡したときは今と同じく明示の版のプランになる(#2354 の再現の手順で確かめる) +- [ ] 9. #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件のテストが、参照の部品へ寄せた後も通る。前提 3 の差し込みを解決する関数が、キュー(`supervise_lib/queue_fill.py`・`supervise_lib/queue.py`)に残らない +- [ ] 10. 前提 6 の旧い形のプラン(値を書き込んだプラン)を流すと、書き込まれた値で流れ、実行の記録に出所「プランの値」が残る +- [ ] 11. 宣言の値のうち秘密と宣言された入力(#2335 の I9)は、解決の記録に値が書かれず、出所の名前だけが残る +- [ ] 12. 子の課題 4 件(下の「子の課題」の表)それぞれについて、再現の手順を 1 回通した結果(コマンドと出力の要点)と、閉じた(解消を確かめた)か残した(理由つき)かを、この課題の本文の表に記録する。閉じた課題には、確かめた結果へのリンクを添える +- [ ] 13. 参照を持たない入口とプランの振る舞いが変わらない。全体テストが柱のマージの前後で通る + +## 子の課題 + +受け入れ条件 12 で、柱の仕組みを入れた後に 1 件ずつ再現の手順を通す。「見込み」は要求の整理(2026-11-04)での見立てで、結論ではない。 + +| 課題 | 何が起きるか(要約) | 当たる受け入れ条件 | 見込み | +| --- | --- | --- | --- | +| #2230 | まとめのプランを組む時点でリリース記録の PR が決まらない | 2・3・9 | 解消(実装は 10.27.6 で入っている。寄せた後に受け入れ条件を通して閉じる) | +| #2164 | 作った時点の版数を持つプランが、並行するスプリントの配布の後に使えなくなる | 1・4・5・9 | 解消(同上) | +| #2349 | `--from` が実行条件を見ず、`--resume` が完了したプランを流し直し、差分の無い PR で止まる | 6・7 | 1 つ目と 2 つ目は解消。3 つ目(差分なしの `pr`)は残す見込み(対象範囲の「含まない」) | +| #2354 | 開発ループのスプリントが版を明示の版で持ち、並行するスプリントと版がぶつかると止まる | 8 | 解消 | + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 参照を持たない入口の起動に足す時間は、#2335 の上限 150 ms(核全体)の内側に収まる。GitHub と origin を読む参照は、その参照を持つステップだけが読む(今と同じ) | +| 可用性 | 解決できないときと食い違うときは、待ち続けずに `stopped` か `gate` で返る(受け入れ条件 3・4) | +| 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを、実行の記録の行に実行の識別子つきで書く(受け入れ条件 2) | +| 移行性 | 移す対象: 前提 3 の差し込みと、プランを組む入口が書き込む値。並走: 旧い形のプランは値のまま流す(受け入れ条件 10)。版の記録(`version.json`)は解決の記録として読む。切り戻し: 参照の段の Pull Request と入口を移す Pull Request を、それぞれ revert すれば前の形へ戻る | +| セキュリティ | 受け入れ条件 11。認証・認可(C2)と秘密の扱い(C1)は変えない | +| システム環境 | 版の比較は今と同じ `versions`(optional extras)を使う。extras が無い環境では、核が `stopped`(終了コード 69)と足りない extras の名前を返す(#2335 の受け入れ条件 15) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`sprint-state.py loop sprint` の `--dev` が任意になる(渡せば今と同じ)。承認ゲートの後の食い違いは `gate` で返る。差し込みの書き方(`{queue_record}` など)は旧い形のプランを読むために次の正式版まで受ける | +| データ | 変わる。プランのファイルは解決で書き換わらなくなり、解決した値は実行の記録に残る。版の記録(`version.json`)の置き場と形は設計で決める(未決)。利用者の `.ndf/` は変えない | +| 既存の振る舞い | 変わる。完了の後に変わったプランを `--resume` すると流さずに止まる。`--from` でも実行条件を先に評価する | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`(全体テスト)。参照の種類ごとの解決・解決できない・承認時の値との食い違い・流し直し・旧い形のプランのテスト。#2164・#2230 の既存のテスト | +| 静的解析・構造の検査 | `python3 scripts/check-script-structure.py` | +| 配布物の検証 | `claude plugin validate .` | +| 手動確認(マージ前) | #2349 の再現の手順(実行条件を持つプランを `--from` で始める・完了したプランを含む done を `--resume` する)を、直した版のスクリプトで 1 回ずつ通し、結果を Pull Request に書く | +| 手動確認 | 子の課題 4 件の再現の手順を、本番へ出した版で通し、受け入れ条件 12 の表を埋める(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 参照の部品は `plugins/ndf/scripts/lib/` に置く。既定で働くため `experimental/` に置かない(`AGENTS.md` の「安定版と実験版」)。`factory/` を参照しない | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」と、外部コマンド(`git fetch`・`gh`)の挙動を書く前に実行して確かめる決まり | +| テスト戦略 | 参照の部品は単体テストで解決・失敗・照合を担保し、プランを組む入口はプランのファイルを読むテストで担保する。子の課題は再現の手順で確かめる。`.md` の文言を照合するテストは書かない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | #2335 の設計を正として変更する。全体テストと構造の検査を通す。参照の部品へ寄せた差し込みの関数をキューから消す | +| 確認してから行う(承認ゲート 1・2 で人が見る) | `loop sprint` の `--dev` を任意にする変更(公開インタフェース)。承認ゲート 2 の後の食い違いを `gate` で返す変更 | +| 行わない | 子の課題を 1 件ずつ直す Pull Request(「含まない」の例外を除く)。版の規則を変える。利用者の `.ndf/` を書き換える。承認時の値との食い違いを AI の判断で見逃す(共通原則の承認の扱い) | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 参照へ移す宣言のキーの一覧(`.ndf/pace.json`・`.ndf/release.json` などのうち、ステップの振る舞いを決めるもの) | この柱の設計 | 設計 Pull Request | +| 参照の書き方(今の `{queue_record}` のような文字列の差し込みを続けるか、プランの型の欄にするか) | この柱の設計(柱 P6 のプランの型に従う) | 設計 Pull Request | +| 流し直しの判定(受け入れ条件 7)で、プランの中身の同一性を何で測るか(ファイルの sha256・型で正規化した中身) | この柱の設計 | 設計 Pull Request | +| 版の記録(`version.json`)を実行の記録へ移すか、状態ディレクトリに残して解決の記録から指すか | この柱の設計 | 設計 Pull Request | +| 柱 P3・P6・P8 のマージの前にこの柱の設計を承認したとき、それらの設計が変わった場合の読み直し | この柱の設計の担当(前提 2) | 実装の開始時 | From e4bc28181a3ae5cf5f2f8405dd39960ef86c61ab Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 08:49:03 +0000 Subject: [PATCH 02/39] =?UTF-8?q?Docs:=20=E3=83=97=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E5=8F=82=E7=85=A7=E3=82=92=E6=B5=81=E3=81=99=E7=9B=B4?= =?UTF-8?q?=E5=89=8D=E3=81=AB=E8=A7=A3=E6=B1=BA=E3=81=97=E3=81=A6=E6=89=BF?= =?UTF-8?q?=E8=AA=8D=E6=99=82=E3=81=AE=E5=80=A4=E3=81=A8=E7=85=A7=E3=82=89?= =?UTF-8?q?=E3=81=99=E8=A8=AD=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88?= =?UTF-8?q?#2344=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2335 の柱 P5 の設計。プランは版・記録の PR・前のプランの結果・設定・予算を参照で持ち、参照の部品がステップの直前に解決して実行の記録へ残す。プランのファイルは書き換えない。解決できなければ stopped、承認時の値と食い違えば gate で返す。 - 本体 issues/issue-2344-design.md(434 行)と決定の記録 issues/issue-2344-design-decisions.md(決定 11 件) - 要求の未決 5 件のうち 4 件を決定 1・3・4・7 で決めた。残る 1 件(柱 P3・P6・P8 の設計が変わったときの読み直し)は「未確認のまま残ること」に置いた - 用語集に「解決の控え」「形を決めた設定」「プランのハッシュ」を足し、「版の記録」「版の差し込み」の意味を変えた Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 7 +- docs/glossary/glossary.json | 22 +- issues/issue-2344-design-decisions.md | 122 ++++++++ issues/issue-2344-design.md | 434 ++++++++++++++++++++++++++ 4 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 issues/issue-2344-design-decisions.md create mode 100644 issues/issue-2344-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 4e471982a..2eda17961 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -324,8 +324,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 判断の項目 | `decisions` | 結果 JSON の decisions。スクリプトが決めたことと、その根拠を並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | | 閉じる予定の参照 | — | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | -| 版の差し込み | — | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | — | — | — | -| 版の記録 | — | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む | — | — | — | +| 版の差し込み | — | 前の版の NDF が組んだ配布のプランが、具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。プランの型の移行関数が版の参照へ読み替える(#2344) | — | — | — | +| 版の記録 | — | 解決の控えのうち、版の参照の行。決めた・確かめた版と、決めた根拠と、決め直しの履歴を持ち、流し直しはこれを読む。前の版の NDF が書いた `version.json` は移行関数でこの行へ読み替える(#2344) | — | — | — | | 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない | — | — | — | | 明示の版 | — | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | — | — | — | | 実行の核 | `run_core` | 入口・ステップが必ず通る共通の実行の仕組み。入力の解決・記録・lease・型の確認・再試行と上限・結果の変換を引き受け、ステップは仕事の中身だけを書く(#2335) | — | — | — | @@ -386,6 +386,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | プランの参照 | — | プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・`.ndf/` の設定の 1 つのキー・上限の宣言の 1 つの方針など)。流す直前に実行の核が値へ解決する(#2344) | — | — | — | | 解決の記録 | — | 1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344) | — | — | — | | 承認時の値 | — | 承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344) | — | — | — | +| 解決の控え | — | 流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344) | — | — | — | +| 形を決めた設定 | — | プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値の sha256 をプランが控え、最初のステップの前に今の値と照らし、違えば流さない(#2344) | — | — | — | +| プランのハッシュ | — | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index ec312ce23..5c46a246f 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4792,13 +4792,13 @@ { "term": "版の差し込み", "context": "ndf-workflow", - "meaning": "配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める", + "meaning": "前の版の NDF が組んだ配布のプランが、具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。プランの型の移行関数が版の参照へ読み替える(#2344)", "source": "" }, { "term": "版の記録", "context": "ndf-workflow", - "meaning": "配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む", + "meaning": "解決の控えのうち、版の参照の行。決めた・確かめた版と、決めた根拠と、決め直しの履歴を持ち、流し直しはこれを読む。前の版の NDF が書いた `version.json` は移行関数でこの行へ読み替える(#2344)", "source": "" }, { @@ -5321,6 +5321,24 @@ "context": "ndf-workflow", "meaning": "承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344)", "source": "" + }, + { + "term": "解決の控え", + "context": "ndf-workflow", + "meaning": "流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344)", + "source": "" + }, + { + "term": "形を決めた設定", + "context": "ndf-workflow", + "meaning": "プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値の sha256 をプランが控え、最初のステップの前に今の値と照らし、違えば流さない(#2344)", + "source": "" + }, + { + "term": "プランのハッシュ", + "context": "ndf-workflow", + "meaning": "プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344)", + "source": "" } ] } diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md new file mode 100644 index 000000000..45a5f2b9e --- /dev/null +++ b/issues/issue-2344-design-decisions.md @@ -0,0 +1,122 @@ +# #2344 の設計: 決定の記録 + +設計文書の本体は [issue-2344-design.md](issue-2344-design.md) にある。要求の未決のうち、参照の書き方は決定 1、流し直しの判定は決定 7、 +版の記録の置き場は決定 3、参照へ移す設定のキーは決定 4 が受ける。 + +## 決定の記録 + +### 決定 1: 新しい種類の値を同じ規則で解決できるよう、参照はプランの型の `refs` 欄に名前つきで宣言し、ステップからは `{ref:<名前>}` で指す + +今の差し込みは種類ごとに書き方(`{release_version}`・`{queue_record}`・`{queue_pr:<名>}`・`QUEUE_PRS`)と、それを探す正規表現と、埋める関数を持つ。 +種類を足すたびに 3 つを足すことになり、解決できないときの扱いも関数ごとに違った(`{queue_pr:<名>}` は PR が無ければ `0`、`{queue_prs:<名>}` は止める)。 +宣言を 1 つの欄に集めれば、プランの型が宣言の形を確かめ、参照の部品は宣言の `kind` で種類のモジュールを引くだけになる。ステップの中の書き方は +文字列の中の `{ref:<名前>}` と数の欄の `{"ref": "<名前>"}` の 2 つだけにする。 + +文字列の差し込みを種類ごとに続ける形は採らない。上の 3 つが種類ごとに増え、新しい種類が既存の規則に乗らないためである。 + +根拠: Value 7 / Value 5(MVV 版 5) + +### 決定 2: プランが変わったのが人の編集か解決かを区別できるよう、解決した値はプランのファイルへ書き戻さず、メモリ上のステップにだけ入れる + +今のキューは差し込みを埋めたプランでファイルを上書きする。そのため #2349 で、完了の後にプランが変わったのか差し込みが埋まったのかを判定できず、 +完了したプランを流し直した。書き戻さなければプランのファイルは組んだ入口と人だけが書き、変わったことがそのまま人の編集を意味する。 +解決した値は実行の記録の `ref` 行に残り、どの実行がどの値で流したかは記録から追える。 + +埋めたプランを書き戻し、別に差し込み前のコピー(`version-plan.json`)を持つ形は採らない。コピーとプランの 2 つが正になり、決め直しの途中で落ちたときの +戻し方(今の `ensure_filled`)が要るためである。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 3: 流し直しでも同じ版を使えるよう、版の解決は状態ディレクトリの解決の控え(`refs.json`)に残し、`version.json` は移行関数で読む + +実行の記録は 1 回の起動ごとのファイルで、30 日で消える(#2335 の決定 6)。版はプランの寿命の間ずっと同じ値である必要があり(受け入れ条件 5)、 +実行の記録だけでは流し直しで前の版を引けない。そこでプランの状態ディレクトリに、流し直しでも同じ値を使う参照の控えを 1 つのファイルで置く。 +今その性質を持つのは版だけだが、控えは参照の名前ごとの行で持ち、種類を足しても置き場を増やさない。決め直しは前の値を `history` へ追記し、過去を失わない。 + +`version.json` をそのまま残す形は採らない。同じ性質の参照を足したときに種類ごとのファイルが増えるためである。控えを持たず実行の記録だけに置く形は、上の寿命の理由で採らない。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 4: 古い設定の値で黙って進まないよう、設定は「ステップの中身に入る値」を参照にし、「プランの形を決めた値」は組んだ時点の値を控えて流す前に照らす + +設定には 2 種類ある。導入の確認のコマンドや上限の秒のように、ステップの中身に入るだけの値は、流す直前の値を使えばよい。配布のテンプレートや本番のブランチのように、 +どのステップを置くかを決めた値は、流す時点で読み直してもステップは増減しない。後者を流す時点の値で使うと、組んだ形と設定が食い違ったまま流れる。 +そこで後者は組んだ時点の値の sha256 だけをプランに控え(形を決めた設定)、最初のステップの前に今の値と照らし、違えば組み直すコマンドを添えて止まる。 +どちらに当たるかは設定の契約のキーの行(`plan`)が持ち、設計の表は契約からコピーした一覧とする。 + +すべての設定を参照にする形は採らない。形を決めた値を流す時点で読み直しても、プランのステップは組み直さない限り変わらないためである。 +形を決めた値を照らさない形は、目的の 2 つ目(古い値で黙って進まない)に反するため採らない。 + +根拠: Mission / Value 6 / Value 7(MVV 版 5) + +### 決定 5: 利用者が明示した値を AI が置き換えないよう、引数で渡した版・上限・ブランチは参照にせず値のまま持つ + +`--version` などで渡した値は、利用者か conductor がその値で流すと決めたものである。参照にして流す時点の値へ置き換えると、明示した決定を黙って上書きする。 +#2164 でも明示の版は決め直さず、既出なら止める。値のまま持ち、解決の記録には出所「プランの値(明示)」を残して、流した値の出所を追えるようにする。 +明示の版の既出の判定は、`version` の種類の `explicit` として今と同じに行う。 + +明示の値も参照に寄せ、明示であることを宣言の欄で持つ形は採らない。上限とブランチでは明示の値に確かめることが無く、宣言が増えるだけのためである。 + +根拠: Vision / Value 2(MVV 版 5) + +### 決定 6: 承認ゲート 2 で人が見た版と流す版を照らせるよう、承認資料に「承認した版」の行を足し、本番の入口は入口の契約の `refs` で参照の部品を通る + +今の承認資料は承認したコミットの行だけを持ち、版は承認ゲート 2 の通過の後に系列の版の記録が変わったか(`series_changed`)でしか照らせない。 +承認資料に「承認した版」を書けば、人が見た版と解決した版をそのまま比べられる。照合は承認したコミットと同じく `approved: gate-2` を付けた参照で行い、 +食い違えば `gate` で人へ戻す。今の `series_changed` は `stopped` だが、承認した値との食い違いは人が判断することなので `gate` にする(受け入れ条件 4)。 +本番の入口はプランからも手動でも呼ばれるため、照合を入口の契約の `refs` に置き、核が入口ごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 + +照合をプランのステップの側だけに置く形は採らない。手動で本番の入口を呼んだときに照合が抜けるためである。 +「承認した版」の行が無い旧い承認資料は、`series_changed` の照らし方で読む(次の正式版まで)。 + +根拠: Value 2 / Value 7(MVV 版 5) + +### 決定 7: 完了したプランの流し直しを中身の変更だけで判定できるよう、プランの同一性はプランの型で読んだ中身を正規化した JSON の sha256 で測る + +決定 2 でプランのファイルは解決で変わらなくなるが、`jq` などで整形し直すとキーの並びと空白だけが変わる。ファイルのバイト列の sha256 では、 +中身の同じプランを「変わった」と判定して人へ戻すことになる。型で読んだ値をキーを並べ替えた JSON にしてから取れば、意味の変わった編集だけを拾える。 +ハッシュは実行の記録の `start` 行とキューの done の項目に持ち、再開とキューの `--resume` が今のハッシュと照らす。前のハッシュが無い旧い done では、 +完了したプランを流さない(受け入れ条件 7 の前半)。 + +ファイルの sha256 は上の理由で採らない。流し直しを完了の結果だけで止め、変わっても知らせない形は、#2349 の「流し直すかを知らせて止まる」に反するため採らない。 + +根拠: Value 3 / Value 8(MVV 版 5) + +### 決定 8: 飛ばすはずのプランが開始のステップで流れないよう、実行条件は参照の段の最初に開始のステップによらず 1 回だけ評価する + +実行条件は「このプランを流すか」の判定で、どのステップから始めるかと関係しない。今は開始のステップを渡すと評価を飛ばし(`engine.py:219`)、 +#2349 で取り込み済みのプランが流れた。参照より先に評価すれば、飛ぶプランは版を照会せず、キューが条件を先に打つ手当て(`_release_skipped_by_condition`)も要らなくなる。 + +開始のステップが実行条件の後ろにあるときだけ評価する形は採らない。実行条件はステップではなくプランに付くため、前後の判定に意味が無い。 + +根拠: Value 7 / Value 5(MVV 版 5) + +### 決定 9: 前のプランの結果をキューの外からも同じに解決できるよう、前のプランの結果の参照は状態ディレクトリから読む + +今の `{queue_pr:<名>}` などは、キューが持つ前のステージの結果を先に見て、無ければ同じディレクトリのプランの状態ディレクトリを読む。 +2 つの経路は同じ報告(`report.md` と `work/`)を読むため、後者だけにしても値は変わらない。経路を 1 つにすると、参照の部品はキューに依らず、 +単独で流すとき・再開するとき・キューで流すときに同じ値になる。キューの前のステージ全体の PR(今の `QUEUE_PRS`)は、キューが done の隣に書く一覧から前のステージのプランを引く。 + +キューが結果を参照の部品へ渡す形は採らない。キューの外から流したときに別の経路が要り、2 つの経路が食い違いうるためである。 + +根拠: Value 7 / Value 6(MVV 版 5) + +### 決定 10: 結果を読む側を壊さないよう、参照で止まったプランのキューの項目は `unfilled: true` を保つ + +開発ループ(`lib/sprint_loop.py:481`・`lib/sprint_loop_gate2.py:243`)とキューの要約(`queue.py:340`)は、`unfilled` で「差し込みを埋められずに流さなかった」プランを数える。 +参照で止まったプランは結果が「止まった」になるが、`unfilled` を外すと読む側の数え方が変わる。結果 JSON の鍵は足すだけで消さない(#2335 の移行の表)ため、 +参照で止まった項目にも `unfilled: true` を付け、旧い版の `流さなかった` の項目も読む。 + +`unfilled` を外し、読む側を同じ Pull Request で `止まった` の判定に寄せる形は採らない。旧い版のキューが書いた done を新しい版の開発ループが読むと、数え方が食い違うためである。 + +根拠: Value 6 / Value 1(MVV 版 5) + +### 決定 11: 開発ループのスプリントが版の取り合いで止まらないよう、`loop sprint` の `--dev` を任意にし、省いたら版を渡さない + +#2164 で `new sprint` は `--version` を省けば配布の時点に版を決める。開発ループは `--dev` を必須にして `--version` を必ず渡すため、この経路を選べない(#2354)。 +`--dev` を省いたときは `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない。周回の版は、配布の答えを書く `settle_release` が +今と同じく `versions.prod` に入れる。`--dev` を渡したときは今と同じ明示の版のプランになり、呼び方を変えた利用者の手を止めない。 + +`--dev` を廃止する形は採らない。明示の版で回したい利用者(決定 5)の手段が無くなるためである。 + +根拠: Vision / Value 2(MVV 版 5) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md new file mode 100644 index 000000000..8c68cc787 --- /dev/null +++ b/issues/issue-2344-design.md @@ -0,0 +1,434 @@ +# プランの参照: プランを作り直さずに版・記録の PR・設定・予算を流す直前の値で使い、承認時の値と食い違えば止まる(#2344) + +## 目的 + +- **今どうなっているか**: プランは版・記録の PR・設定から読んだ値・予算の秒を、組んだ時点の値で持つ。版と記録の PR だけはキューが流す前に差し込みを埋めるが、埋めた値でプランのファイルを上書きし、差し込みの種類ごとに別の関数が解決する。`--from` で始めると実行条件を見ずに流れ(#2349)、開発ループのスプリントは明示の版しか持てない(#2354) +- **誰に効くか**: NDF でスプリントを回す conductor と利用者。並行するスプリントの配布の後にプランを作り直す手当て(#2164 の再発 2 回)と、完了したプランの流し直し(#2349 の 24 分・$0.89)が無くなる +- **変えると何が成り立つか**: プランは値の取り先(参照)を持ち、参照の部品がステップの直前に 1 つの規則で解決・記録・照合する。解決できない・承認時の値と食い違うときは、推定の値で進まずに `stopped` か `gate` で返る + +## 適用範囲 + +- **働く範囲**: NDF の配布物のうち、プランを組む入口(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`)、プランを流す処理(キュー・プランの実行・再開の入口)、配布の本番の入口。配布先のリポジトリでも同じに働く。`factory/` のプランは含まない +- **プロジェクトごとに違うもの**: 参照の値は各リポジトリの `.ndf/` の設定・origin の版とタグ・GitHub の Pull Request から読む。どの設定のキーを参照にするかは設定の契約(`plugins/ndf/contracts/settings/`)の行が持ち、ai-plugins の値を部品に置かない +- **当たるモード**: `standard`。#2335 の柱 P5 で、柱 P6(#2339 データの契約)・P8(#2341 入力の契約)・P3(#2342 持ち主の記録)のマージの後に実装する + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 利用者の指示(2026-11-01)「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」「出力だけでなく入力もそろえるべき」 | 利用者の指示の原文 | 値の解決を種類ごとの手当てでなく 1 つの仕組みに寄せること | +| 差し込みを解決する関数が `supervise_lib/queue_fill.py` に 5 つ(`_fill_prs`・`_fill_states`・`_fill_work`・`_fill_record`・`fill_release_version`)と `supervise_lib/queue.py` に 1 つ(`fill_queue_prs`)ある。埋めた値は `fill_queue_pr`・`fill_queue_prs`・`_Fill.ensure_filled` の 3 か所でプランのファイルへ上書きされる(`write_text_atomic(Path(plan), …)` と `write_text_atomic(self.path, …)`) | 実測(2026-11-04 の develop のコード) | 解決の規則が 6 か所に分かれ、プランが変わったのが人の編集か解決かを区別できないこと | +| `supervise_lib/engine.py:219` の `if self.plan.get("実行の条件") and not start:` | 実測(同上) | 開始のステップを渡すと実行条件が評価されないこと(#2349 の 1 つ目の原因) | +| `lib/sprint_loop_cli.py:97` の `s.add_argument("--dev", required=True)` と `lib/sprint_loop.py:294` の `"--version", dev` | 実測(同上) | 開発ループが版を参照にできないこと(#2354 の原因) | + +要求と受け入れ条件は #2344 の本文にある(コピーは [issue-2344-requirements.md](issue-2344-requirements.md) )。 +この文書は「どう作るか」だけを扱う。決定の記録は [issue-2344-design-decisions.md](issue-2344-design-decisions.md) にある。 +上位の設計は [issue-2335-design.md](issue-2335-design.md) で、実行の核・参照の部品(`lib/refs.py`)・不変条件 I14・ドメインイベント E5 はそれに従う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | プランの参照・解決の記録・解決の控え・承認時の値・形を決めた設定・プランのハッシュ・版の記録 | +| リリース(`ndf-release`) | 承認したコミット・承認資料(語の意味は変えない。承認資料に「承認した版」の行を足す) | + +**リリースが供給者、開発ワークフローが顧客の関係(顧客 / 供給者)。** 参照の部品は承認資料の形(承認したコミットと承認した版の行)と +`lib/approved_commit.py` の比較を、リリースが決めたとおりに読む。参照の部品は承認資料を書かない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| プラン | プランを組む入口と人(実行の間は誰も書かない) | プランのファイル | ステップ | 参照の宣言・形を決めた設定・実行条件 | +| 解決の控え | 参照の部品(`lib/refs.py`) | プランの状態ディレクトリ | 控えの行(参照の名前ごと) | 解決した値・出所・決め直しの履歴 | +| 実行 | 実行の核(#2335) | 実行の識別子 | 実行の記録の行 | 解決の記録の行・プランのハッシュ | + +**集約の間は識別子で参照する。** 解決の控えはプランを状態ディレクトリのパスで、実行の記録の `ref` 行は控えを参照の名前で指す。 +1 回の解決が書き換える集約は「解決の控え」だけで、実行の記録へは追記だけを行う。プランは解決で書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | プラン | プランのファイルは参照の解決で書き換わらない。解決した値はメモリ上のステップにだけ入る | 書き込みの経路が無い(解決の部品はプランのパスを書き口として受け取らない) | +| I2 | 実行 | ステップに入る値は、解決した参照・プランの値(明示)・プランの値(旧い形)のどれかの出所を持ち、1 値 1 行で解決の記録に残る | 記録へ書けなければ `stopped`(#2335 の I3) | +| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後・ステップを流す直前に行う。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | +| I4 | 実行 | 解決できない参照を使うステップは流れない。推定の値・空・`0`・`unknown` で埋めない | `stopped`・終了コード 6、`gaps` に `ref:<名前>` と理由 | +| I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 3、`items` に承認時の値と実行時の値 | +| I6 | 解決の控え | 版の参照は控えがあれば控えの版を使う。決め直すのは #2164 の規則に当たるときだけで、前の版と理由を控えの `history` と解決の記録に残す | 規則に当たらない決め直しの経路が無い(決め直しは `release_version` の判定だけが起こす) | +| I7 | プラン | 形を決めた設定の今の値が、プランが控えた値と違えば、プランの最初のステップから流さない | `stopped`・終了コード 2、`gaps` に設定の名前と、`next` に組み直すコマンド | +| I8 | 実行 | 完了したプランを `--from` なしに流し直さない。完了した実行の記録のプランのハッシュと今のハッシュが違えば流さない | `gate`・終了コード 3、`next` に `--from` で流し直すコマンド | +| I9 | 実行 | 秘密と宣言された設定の値は、解決の記録と結果に書かれず、出所の名前だけが残る | 書く前に値を落とす(#2335 の I9 と同じ経路) | + +### ドメインイベント + +要求の E1〜E9 を引き継ぐ。右端は #2335 の番号である。 + +| # | イベント | 発生元 | 受け手 | #2335 | +| --- | --- | --- | --- | --- | +| E1 | プランを参照つきで組んだ | プランを組む入口 | プランのファイル | — | +| E2 | 承認時の値(承認したコミット・承認した版)を承認資料に記録した | 承認ゲート 1・2 の承認(`approved_commit.record_approval`) | 参照の部品(E6) | — | +| E3 | 持ち主の記録を取った | 実行の核(柱 P3) | プランの実行 | E4 | +| E4 | 実行条件を評価した | プランの実行 | プランの実行(飛ばすなら E5〜E8 を行わずに完了) | E5 | +| E5 | 参照を解決した | 参照の部品 | プランの実行・解決の控え | E5 | +| E6 | 解決した値を承認時の値と照らした | 参照の部品 | プランの実行(食い違えば `gate`) | E5 | +| E7 | 解決の記録を書いた | 参照の部品(`ctx.log("ref", …)`) | 実行の記録 | E5 | +| E8 | ステップを流した | プランの実行 | 実行の核 | E7 | +| E9 | 同じプランを流し直し・再開した | 再開の入口(柱 P3)・キュー | プランの実行(ハッシュの照合) | E13 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| プランの参照 | 要求の用語のとおり | 追加済み(要求) | +| 解決の記録 | 要求の用語のとおり。実行の記録の `ref` 行である | 追加済み(要求) | +| 承認時の値 | 要求の用語のとおり | 追加済み(要求) | +| 解決の控え | 流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`) | 追加(`ndf-workflow`) | +| 形を決めた設定 | プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値をプランが控え、流す前に今の値と照らす | 追加(`ndf-workflow`) | +| プランのハッシュ | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る | 追加(`ndf-workflow`) | +| 版の記録 | 解決の控えのうち、版の参照の行 | 意味の変更(`ndf-workflow`) | +| 版の差し込み | 前の版の NDF が組んだプランの `{release_version}`・`{release_base}`。移行関数が版の参照へ読み替える | 意味の変更(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | プランを組む入口が、版・記録の PR・前のプランの結果・設定・予算を参照で書き、形を決めた設定を控える | `new sprint` / `new close`・開発ループ | +| F2 | ステップの直前に参照を解決し、解決の記録を書いてメモリ上のステップへ値を入れる | プランの実行 | +| F3 | 版の参照の解決を控えに残し、流し直しで同じ版を使い、#2164 の規則のときだけ決め直す | 配布のプラン | +| F4 | 承認時の値と照らし、食い違えば `gate` で返す | 本番の配布(プランからも手動の起動からも) | +| F5 | 実行条件を開始のステップによらず先に評価する | プランの実行・再開 | +| F6 | 完了したプランの流し直しをプランのハッシュで判定する | 再開の入口・キュー | +| F7 | 旧い形のプラン(差し込み・値を書き込んだもの)を移行関数で読み、値をそのまま流す | 前の版の NDF が組んだプラン | + +## 構成要素 + +| 要素 | 責務 | 新設 / 変更 | +| --- | --- | --- | +| 参照の部品(`lib/refs.py`) | 参照の宣言の型・種類の表・`resolve(ctx, plan, step)`。種類ごとの解決を遅れて読み、解決の記録と解決の控えを書く。外へ書くのは控えと記録だけ | 新設 | +| 参照の種類(`lib/ref_kinds/`) | 種類ごとに 1 モジュール(`version`・`record_pr`・`plan_result`・`setting`・`budget`・`tip`)。値か失敗の理由を返し、終了コードと出力を持たない | 新設(`queue_fill.py` の 5 つの関数と `queue.fill_queue_prs` を移す) | +| 版の判定(`lib/release_version.py`) | 次に空いている版・既出の判定・自分の配布のマージの有無(変えない) | 変えない | +| 承認したコミット(`lib/approved_commit.py`) | 「承認した版」の行の読み書きを足す。`release_lib/approval.py` の `check_approved`・`release_pr_allowed` を移し、`tip` の種類と配布の本番の入口が同じ関数を呼ぶ | 変更 | +| 承認資料を書く入口(`release-steps.py approval-facts`) | 承認資料に「承認した版」の行を書く | 変更 | +| プランの型(柱 P6 の `lib/model/` のプラン) | `refs`(参照の宣言)・`pins`(形を決めた設定)の欄を持つ。旧い差し込みを参照へ読み替える移行関数を持つ | 変更(柱 P6 の型に欄を足す) | +| 解決の控えの型(`lib/model/` の `refs-store`) | 控えの形と、`version.json` を控えへ読み替える移行関数 | 新設 | +| プランの実行(`supervise_lib/engine.py`) | ステップの直前に実行条件 → 形を決めた設定 → 参照の解決の順に呼ぶ。開始のステップで実行条件を飛ばさない | 変更 | +| キュー(`supervise_lib/queue.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す) | +| 差し込みの解決(`supervise_lib/queue_fill.py`) | — | 削除(`FINISHED`・`named`・`results_named` は `ref_kinds/plan_result.py` へ移す) | +| 再開(`supervise_lib/queue_resume.py`・柱 P3 の `supervise.py resume`) | 完了したプランをプランのハッシュで照らし、違えば `gate` で返す | 変更 | +| プランを組む入口(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した値は値のまま書く | 変更 | +| 開発ループ(`lib/sprint_loop_cli.py`・`lib/sprint_loop.py`) | `--dev` を任意にし、省いたら `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない | 変更 | +| 設定の契約(`plugins/ndf/contracts/settings/`)と読み手(`scripts/contracts_lib/model.py`) | キーの行に `plan`(`ref` / `budget` / `shape`)と `secret` を足す。`supervise.json` の `impl_timeout`・`refactor_budget_minutes` の行を足す | 変更 | +| 入口の契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番の入口の行に `refs`(`tip` の参照)を足す | 変更 | + +```mermaid +graph TD + NEW[プランを組む入口] -->|参照と形を書く| PL[プランのファイル] + LOOP[開発ループ] --> NEW + Q[キュー] --> EN[プランの実行] + RS[再開] --> EN + EN -->|読む| PL + EN --> RF[参照の部品] + RF --> RK[参照の種類] + RK --> RV[版の判定] + RK --> AC[承認したコミット] + RK --> SC[設定の契約] + RF -->|書く| ST[解決の控え] + RF -->|追記| LG[実行の記録] + AF[承認資料を書く入口] --> AC + PROD[配布の本番の入口] --> RF +``` + +図に描かない要素: プランの型と解決の控えの型(参照の部品とプランの実行が読み書きに使う)、入口の契約(核が配布の本番の入口の `refs` を読む)、差し込みの解決(消す)。 + +### 置き場所 + +```text +plugins/ndf/ +├── contracts/ +│ ├── settings/*.json # 変更: キーの行に plan・secret +│ └── entrypoints/*.json # 変更: 配布の本番の入口に refs +└── scripts/ + ├── lib/ + │ ├── refs.py # 新設 + │ ├── ref_kinds/ # 新設: version・record_pr・plan_result・setting・budget・tip + │ ├── model/ # 変更(柱 P6): プランの refs・pins、refs-store + │ ├── approved_commit.py # 変更 + │ ├── sprint_loop.py / sprint_loop_cli.py # 変更 + │ └── release_version.py # 変えない + ├── release_lib/approval.py # 変更: check_approved を approved_commit へ移す + └── supervise_lib/ + ├── engine.py / queue.py / queue_resume.py # 変更 + ├── queue_fill.py # 削除 + └── templates.py ほかプランを組む 6 ファイル # 変更 +``` + +## 構造 + +```mermaid +classDiagram + class RefSpec { + +name + +kind + +params + +approved + +sticky + } + class Resolution { + +name + +value + +source + +secret + +approved_value + } + class RefFailure { + +name + +reason + +status + } + class StoreRow { + +name + +value + +how + +observed + +history + } + RefSpec ..> Resolution : 解決する + RefSpec ..> RefFailure : 解決できない・食い違う + RefSpec ..> StoreRow : sticky なら読み書き +``` + +- **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`RefFailure.status` は `stopped` か `gate` +- **`check_pins(ctx, plan) -> RefFailure | None`**: 形を決めた設定を今の値と照らす。プランの最初に流すステップの前に 1 回だけ呼ぶ +- **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る +- **種類のモジュールの形**: `resolve(ctx, spec, plan) -> Resolution | RefFailure`。GitHub と origin を読む種類は、その種類を使うステップでだけ読み込まれる + +## データ構造 + +### 参照の宣言(プランの `refs`) + +プランの型の欄 `refs` は、参照の名前 → 宣言の対応である。ステップの文字列の中では `{ref:<名前>}`(版の起点は `{ref:<名前>.base}`)、 +数の欄(`timeout`)では `{"ref": "<名前>"}` で指す。 + +| 種類 | `params` | 解決した値 | `sticky` | 解決できないとき | +| --- | --- | --- | --- | --- | +| `version` | `plugin`・`channel`(`dev` / `prod`)・`level`・`series`(本番が版を読む開発版のプランの名前)・`explicit`(明示の版。任意) | `{version, base}` | 真 | origin の版かタグを読めない・明示の版が既出・系列の版の記録が無い(#2164 の `unreadable` / `taken` / `no_series`) | +| `record_pr` | `issues`(課題の番号の並び) | リリース記録を置いた PR の番号 | 偽 | 記録の PR が見つからない(`record_find` の理由) | +| `plan_result` | `plan`(名前)か `plans: "earlier"`(キューの前のステージ)・`field`(`pr` / `state` / `prs` / `approved`)・`optional` | PR の番号・状態ディレクトリのパス・PR の並び・承認したコミット | 偽 | 名前の一致するプランの完了した結果が無い(`optional` の `state` だけ空を返す) | +| `setting` | `setting`(設定の契約の id とキー。例 `pace.json:auto.verify`) | 設定の値 | 偽 | 設定のファイルかキーが無い・型が違う | +| `budget` | `setting`(任意)・`policy`(上限の宣言の方針の名前)・`unit`(`seconds` / `minutes`)・`margin` | 秒(`margin` が真なら `with_margin` を足す) | 偽 | 設定が型に合わない・方針が無い(`LimitsError`) | +| `tip` | `branch`・`approved_from`(承認したコミットを返す参照の名前)・`allowed_pr`(許すものの参照の名前) | ブランチの先端の SHA | 偽 | 先端を読めない | + +`approved`(任意)は承認時の値を持つ参照に付け、`gate-2` を書く。`version` では承認資料の「承認した版」と、`tip` では承認したコミットと +`compare_approved` で照らす。承認資料に「承認した版」の行が無い旧い資料では、#2164 の `series_changed`(承認ゲート 2 の通過の後に系列の版の記録が変わった)で照らす。 + +**引数で明示した値は参照にしない。** `--version`・`--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` を渡したときは +値のまま書き、解決の記録の出所は「プランの値(明示)」になる(決定 5)。 + +### 参照にする設定のキー + +設定の契約のキーの行の `plan` が、この表を正として持つ(設計の表は契約からコピーした一覧で、食い違えば契約が正)。表は `supervise_lib/decl.py` の `apply_decls` と `sprint_routes.py` が読む項目から作った(2026-11-04 の develop)。 + +| 設定 | キー | ステップのどこに入るか | `plan` | +| --- | --- | --- | --- | +| `pace.json` | `<進め方>.verify` | 導入の確認のコマンド(`sprint_routes.pace_verify`) | `ref` | +| `supervise.json` | `impl_timeout` | 実装のステップの `timeout` | `budget` | +| `supervise.json` | `refactor_budget_minutes` | リファクタリングのステップの `timeout` と `--budget-minutes`(`templates._inspect_steps`) | `budget` | +| `project.json` | `test_duration` | テストと CI の待ちの `timeout`(`verify_steps.plan_limits`) | `budget` | +| `worktree.json` | `base_branch` | 起点・宛先(ステップの数は変えないが、変わればスプリントの宛先が変わる) | `shape` | +| `worktree.json` | `production_branch` | 承認ゲート 2 と本番のステップを置くか | `shape` | +| `supervise.json` | `release` | 配布のテンプレート | `shape` | +| `supervise.json` | `sync_checks`・`format` | sync と format のステップを置くか | `shape` | +| `supervise.json` | `test` | テストのステップ(`project.json` に `test` が無いとき) | `shape` | +| `project.json` | `test`・`delivery` | テストのステップ・配布の経路 | `shape` | +| `pace.json` | `<進め方>.enabled` | 経路を置くか | `shape` | + +**形を決めた設定(`pins`)** は `{setting, digest}` の並びで、`digest` は組んだ時点の値を正規化した JSON の sha256 である。値そのものは持たない +(秘密の値をプランへ書かないため)。今の値が無いことも「違う」として扱う。 + +### 解決の控え(`<状態ディレクトリ>/refs.json`) + +| 鍵 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `kind` / `version` | str / int | 許さない | `refs-store` / 1(柱 P6 の型の版) | +| `rows.<名前>.value` | object | 許さない | 解決した値(版なら `{version, base}`) | +| `rows.<名前>.how` | str | 許さない | `decided`(次に空いている版)/ `explicit` / `series` | +| `rows.<名前>.observed` | object | 許さない | 決めたときに読んだ origin の版とタグ(`Observed.as_dict()`) | +| `rows.<名前>.at` | str | 許さない | 決めた時刻(UTC の ISO 8601) | +| `rows.<名前>.history` | list | 空のリストを許す | 決め直しごとに `{from, to, reason, existing, observed, left, at}` を追記する。消さない | + +**時系列は追記で持つ。** 決め直しは `value` を上書きするが、前の値は `history` に残る。`version.json` は移行関数で `rows.version` へ読み替え、 +書くのは `refs.json` だけにする。`version-plan.json`(差し込み前のプランのコピー)は、プランを書き換えないため作らない。 + +### 実行の記録の `ref` 行 + +#2335 の実行の記録の行(`event: "ref"`)の `fields` は次の形である。1 つの参照の解決ごとに 1 行。 + +| 鍵 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `step` | str | 許さない | ステップの id。形を決めた設定の照合は `pins` | +| `name` / `kind` | str | 許さない | 参照の名前と種類。プランの値は `kind: "literal"` | +| `source` | str | 許さない | `ref`(解決した)/ `store`(控えの値)/ `plan:explicit` / `plan:legacy` | +| `value` | any | 許す | 解決した値。秘密の設定は鍵ごと書かない(I9) | +| `outcome` | str | 許さない | `resolved` / `redecided` / `unresolved` / `mismatch` / `match` | +| `approved` | object | 許す | 承認時の値(照らしたときだけ) | +| `reason` | str | 許す | 解決できない・食い違う・決め直した理由 | + +実行の記録の `start` 行の `fields` に `plan` と `plan_digest` を足す。キューの done の項目にも `plan_digest` を足す(鍵は足すだけ)。 + +### CRUD + +| 機能 | プランのファイル | 解決の控え | 実行の記録 | 承認資料 | done | +| --- | --- | --- | --- | --- | --- | +| F1 組む | C | — | — | — | — | +| F2 解決 | R | R | C(`ref`) | — | — | +| F3 版 | R | C・U | C | — | — | +| F4 承認時の値 | R | R | C | R | — | +| F5 実行条件 | R | — | C | — | — | +| F6 流し直し | R | — | R(`start`) | — | R・C | +| F7 旧い形 | R | R(`version.json`) | C | — | — | + +書く相手が 2 つ以上ある機能は F3(控えと記録)で、記録は追記だけのため、控えを書いてから記録を書く順にする(落ちたら次の解決が控えを読み、同じ版の `ref` 行を書き直す)。 + +## 入出力の契約 + +| 項目 | 約束 | +| --- | --- | +| `sprint-state.py loop sprint` | `--dev` を任意にする。渡せば今と同じく `new sprint --version <開発版>` を組む。省けば `--version` を渡さず、周回の `versions` は配布の答え(`settle_release`)で埋まる | +| 解決できないときの結果 | ステップを流さず、プランの結果は「止まった」、結果 JSON は `status: "stopped"`・終了コード 6、`gaps` に `ref:<名前>`、`summary` に参照の名前と理由 | +| 形を決めた設定が違うときの結果 | `status: "stopped"`・終了コード 2、`gaps` に `pin:<設定>`、`next` に同じ引数で組み直す `supervise.py new` のコマンド | +| 承認時の値と食い違うときの結果 | `status: "gate"`・終了コード 3、`items` に `{kind: "ref-mismatch", name, approved, actual, reason}` | +| 完了したプランが変わったときの結果 | 再開とキューの `--resume` は `status: "gate"`・終了コード 3、`items` に `{kind: "plan-changed", plan, digest_done, digest_now}`、`next` に `supervise.py resume <実行の識別子> --from <プラン>=<ステップ>` | +| キューの done | 参照で止まったプランの項目は `result: "止まった"`・`unfilled: true`・`reason`。旧い版の `result: "流さなかった"`・`unfilled: true` の項目も読む | +| 承認資料 | 「2. 承認の判断に使うもの」の表に `| 承認した版 | <版> |` の行を足す。承認の記録(`record_approval`)は今と同じ | +| 旧い形のプラン | `{release_version}`・`{release_base}`・`{queue_record}`・`{queue_pr:<名>}`・`{queue_state(?):<名>}`・`{queue_prs:<名>}`・`{queue_approved:<名>}`・`QUEUE_PRS` は、プランの型の移行関数が参照へ読み替える。受けるのは次の正式版まで | + +## 処理の流れ + +```mermaid +sequenceDiagram + participant C as キュー・再開 + participant E as プランの実行 + participant F as 参照の部品 + C->>E: プランを流す(開始のステップ) + E->>E: 持ち主の記録を取る(E3、柱 P3) + E->>E: 実行条件を評価(E4) + alt 飛ばす + E-->>C: 完了(実行条件に当たらない) + end + E->>F: check_pins(最初のステップの前) + alt 形を決めた設定が違う + E-->>C: stopped・2 + end + loop ステップごと + E->>F: resolve(ステップ) + alt 解決できない + F-->>E: RefFailure(stopped) + E-->>C: stopped・6 + else 食い違う + F-->>E: RefFailure(gate) + E-->>C: gate・3 + end + F-->>E: 値を入れたステップ + E->>E: ステップを流す(E8) + end + E-->>C: 結果 +``` + +`resolve` の中(E5〜E7)は次のとおりである。 + +```mermaid +sequenceDiagram + participant F as 参照の部品 + participant K as 参照の種類 + participant S as 解決の控え + participant R as 実行の記録 + F->>S: sticky の参照の控えを読む + F->>K: 種類ごとに解決(E5) + K-->>F: 値か理由 + F->>F: 承認時の値と照らす(E6) + F->>S: 決めた・決め直した版を書く + F->>R: ref 行を書く(E7) +``` + +図に描かない要素: 版の判定と承認したコミット(参照の種類の中で呼ぶ)、プランの型と解決の控えの型(読み書きの型)、プランを組む入口・開発ループ・設定の契約(流す前の E1 で働く)、 +承認資料を書く入口(E2 で働く)、入口の契約と配布の本番の入口(下の 2 つ目の項目)、差し込みの解決(消す)。 + +- **実行条件は E4 で 1 回だけ評価する。** 今の `engine.py` の `and not start` を外し、`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない +- **配布の本番の入口は、入口の契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`release_lib/approval.py` から `lib/approved_commit.py` へ移した `check_approved` を呼ぶ箇所はこの 1 つになる +- **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く +- **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う + +### 流し直しの判定(E9) + +```mermaid +stateDiagram-v2 + [*] --> 照合: 再開・--resume + 照合 --> 流す: 前の結果が完了でない + 照合 --> 流す: --from がある + 照合 --> 流さない: 完了でハッシュが同じ + 照合 --> 流さない: 完了で前のハッシュが無い(旧い done) + 照合 --> 人へ戻す: 完了でハッシュが違う + 流す --> [*] + 流さない --> [*] + 人へ戻す --> [*] +``` + +`--from` は柱 P3 の決まりどおり常に勝つ(#2342 の I9)。流し直す手段は `--from` の 1 つで、「人へ戻す」の結果はそのコマンドを `next` に出す。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 参照を持たない入口の起動に足す時間は #2335 の上限 150 ms の内側。GitHub と origin はその参照を持つステップだけが読む | 核は入口の契約に `refs` が無ければ参照の部品を読み込まない。種類のモジュールは使う種類だけを読み込む | 参照を持たない入口の `--help` を前後で 10 回ずつ測り、差の中央値を実装の Pull Request に書く。GitHub を呼ぶ関数を差し替え、参照を持たないプランで呼ばれないことをテストで見る | +| 可用性 | 解決できない・食い違うときは待ち続けずに返る | 種類のモジュールは待ちを持たず、外部の呼び出しは上限の部品(柱 P4)の 1 回の上限で打ち切る | 解決できない・食い違うテストが `stopped` / `gate` で返る | +| 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを実行の識別子つきで書く | `ref` 行の `outcome` に 5 つの値 | 種類ごとのテストで `ref` 行の `outcome` を見る | +| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)と入口の移し(プランを組む入口・開発ループ・設定の契約)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | +| セキュリティ | 秘密と宣言された入力は値を書かない。C1・C2 を変えない | 設定の契約の `secret` が真のキーは、`ref` 行の `value` を書かず、`pins` は digest だけを持つ | 秘密と宣言したキーを参照にしたテスト | +| システム環境 | 版の比較は `versions`(extras)。無い環境では `stopped`・69 | `version` の種類だけが `versions` を読み込み、読めなければ `deps` の判定で返す | extras を外した環境で版の参照を持つプランを流すテスト | + +## 決定の記録 + +決定の記録は [issue-2344-design-decisions.md](issue-2344-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1 | 版を明示しない `new sprint`・`new close`・`loop sprint` が組むプランのファイルに、版数・記録の PR の番号・上の表の `ref` / `budget` の値・予算の秒が無く、`refs` と `pins` がある | 組む時点に `with_margin` の秒を書く/`pace_verify` の値を書く | +| 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | +| 3・I4 | 記録の PR が無い・origin を読めない・設定のキーが無い・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | +| 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | +| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない | `and not start` を戻す/参照を実行条件より先に解決する | +| 7・I8 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | +| I7 | 組んだ後に `production_branch` か `release` を変えると最初のステップが流れず、`stopped`・2 と組み直すコマンド | 形を決めた設定を照らさない | +| 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば明示の版 | `--dev` を省いたときに既定の版を入れる | +| 9 | #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件の既存のテストが通る。`supervise_lib/queue_fill.py` が無く、`queue.py` に `fill_` で始まる関数が無い | 差し込みの関数をキューに残して二重に解決する | +| 10・F7 | 前の版が組んだプラン(差し込みのもの・値を書き込んだもの)と `version.json` を持つ状態ディレクトリで流れ、`ref` 行の `source` が `plan:legacy` / `store` | 移行関数を外す/旧い差し込みを文字のまま流す | +| 11・I9 | 秘密と宣言したキーを参照にすると、記録と結果に値が無く出所の名前だけがある | `secret` を見ずに `value` を書く | +| 12 | (手動)子の課題 4 件の再現の手順を本番へ出した版で通し、課題の本文の表に結果を書く | — | +| 13 | 参照を持たない入口とプランの振る舞いと結果が前と同じ。全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | 参照を持たないプランでも参照の部品が GitHub を読む | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2344 | 実装する | — | `plugins/ndf/scripts/lib/refs.py`、`plugins/ndf/scripts/lib/ref_kinds/`、`plugins/ndf/scripts/lib/model/`、`plugins/ndf/scripts/lib/approved_commit.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/release_lib/approval.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/contracts/settings/`、`plugins/ndf/contracts/entrypoints/`、`scripts/contracts_lib/model.py`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +子の課題 4 件(#2230・#2164・#2349・#2354)は表に載せない。表の行はスプリントの Pull Request で閉じるが、子の課題は本番へ出した版で再現の手順を通してから、 +閉じるか残すかを決める(受け入れ条件 12)。#2349 の 3 つ目の事象(差分の無い実装のプランで `pr` が終了コード 1)は柱 P1(#2337)の結果の契約の問題で、この柱では直さない(要求の「含まない」)。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 柱 P3・P6・P8 の設計 | 2026-11-04 の時点で P6(#2339)と P8(#2341)の設計文書は無く、P3(#2342)は設計 Pull Request 2480 が開いた状態である。この設計はそれらの語(`ctx.resolve`・`ctx.log`・プランの型・入力の宣言・`supervise.py resume`)にだけ依る。形が変わったら実装の開始時に読み直す(要求の未決 5) | +| 上限の方針の名前 | `budget` の種類が引く方針の名前は柱 P4(#2343)の上限の宣言が決める。この設計は名前を決めない | +| プランのキーの名前 | #2335 の決定 4 でプランの日本語のキーは英語へそろう。この文書の `実行の条件`・`版` などは今の名前で書いた。英語の名前は柱 P6 が決める | +| 形を決めた設定の範囲 | `base_branch` を `shape` に置いたのは、宛先が変わればスプリントの PR の向きが変わるためである。値だけを差し替えて流してよいかは、実装の前に `new sprint` が `base_branch` を書く箇所を数えて確かめる | +| 承認した版の行の書き手 | 承認資料を書く `approval-facts` が、配布の本番のプランの版の参照をどの時点で読むか(承認ゲート 2 の提示の前に版が決まっていること)は、提示のプランの流れで確かめる | +| 設定のキーの一覧の網羅 | 表は `apply_decls` と `sprint_routes` の読む項目から作った。ほかのプランを組む関数が設定を読んでステップへ書いていないかは、実装の開始時に `read_decl` と `read_pace` の呼び出し元を数えて確かめる | +| 並行するスプリントの版の取り合い | 2 つのスプリントが同時に次に空いている版を決めたときは、#2164 の `own_released` の確かめで後から決め直す。版の参照を持ち主の記録で排他にするかは測っていない(#2354 の起票者の推定 2 点目) | From 8d1220912b0e3f4dfbe52441985ed8f2cc37796c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 08:54:19 +0000 Subject: [PATCH 03/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E5=86=85=E5=AE=B9=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=95=E3=81=9A=E3=81=AB=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 長い文を分け、列挙と対比を表・箇条書きへ移した - 処理の流れ・構成要素の章を小見出しで分けた - JSON のキーを指す「鍵」を、本文の他の箇所と同じ「キー」にそろえた Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design-decisions.md | 89 +++++++++++++++++---------- issues/issue-2344-design.md | 73 ++++++++++++++++------ 2 files changed, 110 insertions(+), 52 deletions(-) diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md index 45a5f2b9e..c10a1f679 100644 --- a/issues/issue-2344-design-decisions.md +++ b/issues/issue-2344-design-decisions.md @@ -7,8 +7,13 @@ ### 決定 1: 新しい種類の値を同じ規則で解決できるよう、参照はプランの型の `refs` 欄に名前つきで宣言し、ステップからは `{ref:<名前>}` で指す -今の差し込みは種類ごとに書き方(`{release_version}`・`{queue_record}`・`{queue_pr:<名>}`・`QUEUE_PRS`)と、それを探す正規表現と、埋める関数を持つ。 -種類を足すたびに 3 つを足すことになり、解決できないときの扱いも関数ごとに違った(`{queue_pr:<名>}` は PR が無ければ `0`、`{queue_prs:<名>}` は止める)。 +今の差し込みは、種類ごとに次の 3 つを持つ。 + +- 書き方(`{release_version}`・`{queue_record}`・`{queue_pr:<名>}`・`QUEUE_PRS`) +- それを探す正規表現 +- 埋める関数 + +種類を足すたびに 3 つを足すことになる。解決できないときの扱いも関数ごとに違った(`{queue_pr:<名>}` は PR が無ければ `0`、`{queue_prs:<名>}` は止める)。 宣言を 1 つの欄に集めれば、プランの型が宣言の形を確かめ、参照の部品は宣言の `kind` で種類のモジュールを引くだけになる。ステップの中の書き方は 文字列の中の `{ref:<名前>}` と数の欄の `{"ref": "<名前>"}` の 2 つだけにする。 @@ -18,30 +23,37 @@ ### 決定 2: プランが変わったのが人の編集か解決かを区別できるよう、解決した値はプランのファイルへ書き戻さず、メモリ上のステップにだけ入れる -今のキューは差し込みを埋めたプランでファイルを上書きする。そのため #2349 で、完了の後にプランが変わったのか差し込みが埋まったのかを判定できず、 -完了したプランを流し直した。書き戻さなければプランのファイルは組んだ入口と人だけが書き、変わったことがそのまま人の編集を意味する。 +今のキューは差し込みを埋めたプランでファイルを上書きする。そのため #2349 では、完了の後にプランが変わったのか差し込みが埋まったのかを判定できなかった。 +その結果、完了したプランを流し直した。書き戻さなければ、プランのファイルは組んだ入口と人だけが書く。変わったことがそのまま人の編集を意味する。 解決した値は実行の記録の `ref` 行に残り、どの実行がどの値で流したかは記録から追える。 -埋めたプランを書き戻し、別に差し込み前のコピー(`version-plan.json`)を持つ形は採らない。コピーとプランの 2 つが正になり、決め直しの途中で落ちたときの -戻し方(今の `ensure_filled`)が要るためである。 +埋めたプランを書き戻し、別に差し込み前のコピー(`version-plan.json`)を持つ形は採らない。コピーとプランの 2 つが正になるためである。 +決め直しの途中で落ちたときの戻し方(今の `ensure_filled`)も要る。 根拠: Value 7 / Value 8(MVV 版 5) ### 決定 3: 流し直しでも同じ版を使えるよう、版の解決は状態ディレクトリの解決の控え(`refs.json`)に残し、`version.json` は移行関数で読む -実行の記録は 1 回の起動ごとのファイルで、30 日で消える(#2335 の決定 6)。版はプランの寿命の間ずっと同じ値である必要があり(受け入れ条件 5)、 -実行の記録だけでは流し直しで前の版を引けない。そこでプランの状態ディレクトリに、流し直しでも同じ値を使う参照の控えを 1 つのファイルで置く。 -今その性質を持つのは版だけだが、控えは参照の名前ごとの行で持ち、種類を足しても置き場を増やさない。決め直しは前の値を `history` へ追記し、過去を失わない。 +実行の記録は 1 回の起動ごとのファイルで、30 日で消える(#2335 の決定 6)。版はプランの寿命の間ずっと同じ値である必要がある(受け入れ条件 5)。 +実行の記録だけでは、流し直しで前の版を引けない。そこでプランの状態ディレクトリに、流し直しでも同じ値を使う参照の控えを 1 つのファイルで置く。 +今その性質を持つのは版だけである。控えは参照の名前ごとの行で持ち、種類を足しても置き場を増やさない。決め直しは前の値を `history` へ追記し、過去を失わない。 -`version.json` をそのまま残す形は採らない。同じ性質の参照を足したときに種類ごとのファイルが増えるためである。控えを持たず実行の記録だけに置く形は、上の寿命の理由で採らない。 +`version.json` をそのまま残す形は採らない。同じ性質の参照を足したときに種類ごとのファイルが増えるためである。 +控えを持たず実行の記録だけに置く形は、上の寿命の理由で採らない。 根拠: Value 7 / Value 8(MVV 版 5) ### 決定 4: 古い設定の値で黙って進まないよう、設定は「ステップの中身に入る値」を参照にし、「プランの形を決めた値」は組んだ時点の値を控えて流す前に照らす -設定には 2 種類ある。導入の確認のコマンドや上限の秒のように、ステップの中身に入るだけの値は、流す直前の値を使えばよい。配布のテンプレートや本番のブランチのように、 -どのステップを置くかを決めた値は、流す時点で読み直してもステップは増減しない。後者を流す時点の値で使うと、組んだ形と設定が食い違ったまま流れる。 -そこで後者は組んだ時点の値の sha256 だけをプランに控え(形を決めた設定)、最初のステップの前に今の値と照らし、違えば組み直すコマンドを添えて止まる。 +設定には 2 種類ある。 + +| 種類 | 例 | 流す時点で読み直すと | +| --- | --- | --- | +| ステップの中身に入るだけの値 | 導入の確認のコマンド・上限の秒 | 流す直前の値を使えばよい | +| どのステップを置くかを決めた値 | 配布のテンプレート・本番のブランチ | ステップは増減しない | + +後者を流す時点の値で使うと、組んだ形と設定が食い違ったまま流れる。そこで後者は、組んだ時点の値の sha256 だけをプランに控える(形を決めた設定)。 +最初のステップの前に今の値と照らし、違えば組み直すコマンドを添えて止まる。 どちらに当たるかは設定の契約のキーの行(`plan`)が持ち、設計の表は契約からコピーした一覧とする。 すべての設定を参照にする形は採らない。形を決めた値を流す時点で読み直しても、プランのステップは組み直さない限り変わらないためである。 @@ -52,7 +64,7 @@ ### 決定 5: 利用者が明示した値を AI が置き換えないよう、引数で渡した版・上限・ブランチは参照にせず値のまま持つ `--version` などで渡した値は、利用者か conductor がその値で流すと決めたものである。参照にして流す時点の値へ置き換えると、明示した決定を黙って上書きする。 -#2164 でも明示の版は決め直さず、既出なら止める。値のまま持ち、解決の記録には出所「プランの値(明示)」を残して、流した値の出所を追えるようにする。 +#2164 でも明示の版は決め直さず、既出なら止める。値のまま持ち、解決の記録には出所「プランの値(明示)」を残す。流した値の出所を追えるようにするためである。 明示の版の既出の判定は、`version` の種類の `explicit` として今と同じに行う。 明示の値も参照に寄せ、明示であることを宣言の欄で持つ形は採らない。上限とブランチでは明示の値に確かめることが無く、宣言が増えるだけのためである。 @@ -61,10 +73,10 @@ ### 決定 6: 承認ゲート 2 で人が見た版と流す版を照らせるよう、承認資料に「承認した版」の行を足し、本番の入口は入口の契約の `refs` で参照の部品を通る -今の承認資料は承認したコミットの行だけを持ち、版は承認ゲート 2 の通過の後に系列の版の記録が変わったか(`series_changed`)でしか照らせない。 +今の承認資料は承認したコミットの行だけを持つ。版は、承認ゲート 2 の通過の後に系列の版の記録が変わったか(`series_changed`)でしか照らせない。 承認資料に「承認した版」を書けば、人が見た版と解決した版をそのまま比べられる。照合は承認したコミットと同じく `approved: gate-2` を付けた参照で行い、 -食い違えば `gate` で人へ戻す。今の `series_changed` は `stopped` だが、承認した値との食い違いは人が判断することなので `gate` にする(受け入れ条件 4)。 -本番の入口はプランからも手動でも呼ばれるため、照合を入口の契約の `refs` に置き、核が入口ごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 +食い違えば `gate` で人へ戻す。今の `series_changed` は `stopped` である。承認した値との食い違いは人が判断することなので `gate` にする(受け入れ条件 4)。 +本番の入口はプランからも手動でも呼ばれる。そのため照合を入口の契約の `refs` に置き、核が入口ごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 照合をプランのステップの側だけに置く形は採らない。手動で本番の入口を呼んだときに照合が抜けるためである。 「承認した版」の行が無い旧い承認資料は、`series_changed` の照らし方で読む(次の正式版まで)。 @@ -73,19 +85,19 @@ ### 決定 7: 完了したプランの流し直しを中身の変更だけで判定できるよう、プランの同一性はプランの型で読んだ中身を正規化した JSON の sha256 で測る -決定 2 でプランのファイルは解決で変わらなくなるが、`jq` などで整形し直すとキーの並びと空白だけが変わる。ファイルのバイト列の sha256 では、 -中身の同じプランを「変わった」と判定して人へ戻すことになる。型で読んだ値をキーを並べ替えた JSON にしてから取れば、意味の変わった編集だけを拾える。 -ハッシュは実行の記録の `start` 行とキューの done の項目に持ち、再開とキューの `--resume` が今のハッシュと照らす。前のハッシュが無い旧い done では、 -完了したプランを流さない(受け入れ条件 7 の前半)。 +決定 2 でプランのファイルは解決で変わらなくなる。ただし `jq` などで整形し直すと、キーの並びと空白だけが変わる。 +ファイルのバイト列の sha256 では、中身の同じプランを「変わった」と判定して人へ戻すことになる。型で読んだ値をキーを並べ替えた JSON にしてから取れば、意味の変わった編集だけを拾える。 +ハッシュは実行の記録の `start` 行とキューの done の項目に持ち、再開とキューの `--resume` が今のハッシュと照らす。 +前のハッシュが無い旧い done では、完了したプランを流さない(受け入れ条件 7 の前半)。 -ファイルの sha256 は上の理由で採らない。流し直しを完了の結果だけで止め、変わっても知らせない形は、#2349 の「流し直すかを知らせて止まる」に反するため採らない。 +ファイルの sha256 は上の理由で採らない。流し直しを完了の結果だけで止め、変わっても知らせない形も採らない。#2349 の「流し直すかを知らせて止まる」に反するためである。 根拠: Value 3 / Value 8(MVV 版 5) ### 決定 8: 飛ばすはずのプランが開始のステップで流れないよう、実行条件は参照の段の最初に開始のステップによらず 1 回だけ評価する -実行条件は「このプランを流すか」の判定で、どのステップから始めるかと関係しない。今は開始のステップを渡すと評価を飛ばし(`engine.py:219`)、 -#2349 で取り込み済みのプランが流れた。参照より先に評価すれば、飛ぶプランは版を照会せず、キューが条件を先に打つ手当て(`_release_skipped_by_condition`)も要らなくなる。 +実行条件は「このプランを流すか」の判定で、どのステップから始めるかと関係しない。今は開始のステップを渡すと評価を飛ばす(`engine.py:219`)。 +そのため #2349 で取り込み済みのプランが流れた。参照より先に評価すれば、飛ぶプランは版を照会しない。キューが条件を先に打つ手当て(`_release_skipped_by_condition`)も要らなくなる。 開始のステップが実行条件の後ろにあるときだけ評価する形は採らない。実行条件はステップではなくプランに付くため、前後の判定に意味が無い。 @@ -93,9 +105,14 @@ ### 決定 9: 前のプランの結果をキューの外からも同じに解決できるよう、前のプランの結果の参照は状態ディレクトリから読む -今の `{queue_pr:<名>}` などは、キューが持つ前のステージの結果を先に見て、無ければ同じディレクトリのプランの状態ディレクトリを読む。 -2 つの経路は同じ報告(`report.md` と `work/`)を読むため、後者だけにしても値は変わらない。経路を 1 つにすると、参照の部品はキューに依らず、 -単独で流すとき・再開するとき・キューで流すときに同じ値になる。キューの前のステージ全体の PR(今の `QUEUE_PRS`)は、キューが done の隣に書く一覧から前のステージのプランを引く。 +今の `{queue_pr:<名>}` などは、次の 2 つの経路を順に見る。 + +1. キューが持つ前のステージの結果 +2. 無ければ、同じディレクトリのプランの状態ディレクトリ + +2 つの経路は同じ報告(`report.md` と `work/`)を読むため、後者だけにしても値は変わらない。経路を 1 つにすると、参照の部品はキューに依らない。 +単独で流すとき・再開するとき・キューで流すときに同じ値になる。 +キューの前のステージ全体の PR(今の `QUEUE_PRS`)は、キューが done の隣に書く一覧から前のステージのプランを引く。 キューが結果を参照の部品へ渡す形は採らない。キューの外から流したときに別の経路が要り、2 つの経路が食い違いうるためである。 @@ -103,9 +120,13 @@ ### 決定 10: 結果を読む側を壊さないよう、参照で止まったプランのキューの項目は `unfilled: true` を保つ -開発ループ(`lib/sprint_loop.py:481`・`lib/sprint_loop_gate2.py:243`)とキューの要約(`queue.py:340`)は、`unfilled` で「差し込みを埋められずに流さなかった」プランを数える。 -参照で止まったプランは結果が「止まった」になるが、`unfilled` を外すと読む側の数え方が変わる。結果 JSON の鍵は足すだけで消さない(#2335 の移行の表)ため、 -参照で止まった項目にも `unfilled: true` を付け、旧い版の `流さなかった` の項目も読む。 +次の読む側は、`unfilled` で「差し込みを埋められずに流さなかった」プランを数える。 + +- 開発ループ(`lib/sprint_loop.py:481`・`lib/sprint_loop_gate2.py:243`) +- キューの要約(`queue.py:340`) + +参照で止まったプランは結果が「止まった」になるが、`unfilled` を外すと読む側の数え方が変わる。結果 JSON のキーは足すだけで消さない(#2335 の移行の表)。 +そのため、参照で止まった項目にも `unfilled: true` を付け、旧い版の `流さなかった` の項目も読む。 `unfilled` を外し、読む側を同じ Pull Request で `止まった` の判定に寄せる形は採らない。旧い版のキューが書いた done を新しい版の開発ループが読むと、数え方が食い違うためである。 @@ -114,8 +135,12 @@ ### 決定 11: 開発ループのスプリントが版の取り合いで止まらないよう、`loop sprint` の `--dev` を任意にし、省いたら版を渡さない #2164 で `new sprint` は `--version` を省けば配布の時点に版を決める。開発ループは `--dev` を必須にして `--version` を必ず渡すため、この経路を選べない(#2354)。 -`--dev` を省いたときは `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない。周回の版は、配布の答えを書く `settle_release` が -今と同じく `versions.prod` に入れる。`--dev` を渡したときは今と同じ明示の版のプランになり、呼び方を変えた利用者の手を止めない。 +`--dev` を省いたときと渡したときの振る舞いは次のとおりである。 + +| `--dev` | 振る舞い | +| --- | --- | +| 省く | `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない。周回の版は、配布の答えを書く `settle_release` が今と同じく `versions.prod` に入れる | +| 渡す | 今と同じ明示の版のプランになり、呼び方を変えた利用者の手を止めない | `--dev` を廃止する形は採らない。明示の版で回したい利用者(決定 5)の手段が無くなるためである。 diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index 8c68cc787..a4bac32ca 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -8,7 +8,10 @@ ## 適用範囲 -- **働く範囲**: NDF の配布物のうち、プランを組む入口(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`)、プランを流す処理(キュー・プランの実行・再開の入口)、配布の本番の入口。配布先のリポジトリでも同じに働く。`factory/` のプランは含まない +- **働く範囲**: NDF の配布物のうち次の 3 つ。配布先のリポジトリでも同じに働く。`factory/` のプランは含まない + - プランを組む入口(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`) + - プランを流す処理(キュー・プランの実行・再開の入口) + - 配布の本番の入口 - **プロジェクトごとに違うもの**: 参照の値は各リポジトリの `.ndf/` の設定・origin の版とタグ・GitHub の Pull Request から読む。どの設定のキーを参照にするかは設定の契約(`plugins/ndf/contracts/settings/`)の行が持ち、ai-plugins の値を部品に置かない - **当たるモード**: `standard`。#2335 の柱 P5 で、柱 P6(#2339 データの契約)・P8(#2341 入力の契約)・P3(#2342 持ち主の記録)のマージの後に実装する @@ -21,9 +24,9 @@ | `supervise_lib/engine.py:219` の `if self.plan.get("実行の条件") and not start:` | 実測(同上) | 開始のステップを渡すと実行条件が評価されないこと(#2349 の 1 つ目の原因) | | `lib/sprint_loop_cli.py:97` の `s.add_argument("--dev", required=True)` と `lib/sprint_loop.py:294` の `"--version", dev` | 実測(同上) | 開発ループが版を参照にできないこと(#2354 の原因) | -要求と受け入れ条件は #2344 の本文にある(コピーは [issue-2344-requirements.md](issue-2344-requirements.md) )。 +要求と受け入れ条件は #2344 の本文にある。コピーは [issue-2344-requirements.md](issue-2344-requirements.md) にある。 この文書は「どう作るか」だけを扱う。決定の記録は [issue-2344-design-decisions.md](issue-2344-design-decisions.md) にある。 -上位の設計は [issue-2335-design.md](issue-2335-design.md) で、実行の核・参照の部品(`lib/refs.py`)・不変条件 I14・ドメインイベント E5 はそれに従う。 +上位の設計は [issue-2335-design.md](issue-2335-design.md) である。実行の核・参照の部品(`lib/refs.py`)・不変条件 I14・ドメインイベント E5 はそれに従う。 ## ドメインモデル @@ -123,6 +126,8 @@ | 設定の契約(`plugins/ndf/contracts/settings/`)と読み手(`scripts/contracts_lib/model.py`) | キーの行に `plan`(`ref` / `budget` / `shape`)と `secret` を足す。`supervise.json` の `impl_timeout`・`refactor_budget_minutes` の行を足す | 変更 | | 入口の契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番の入口の行に `refs`(`tip` の参照)を足す | 変更 | +### 要素の関係 + ```mermaid graph TD NEW[プランを組む入口] -->|参照と形を書く| PL[プランのファイル] @@ -141,7 +146,13 @@ graph TD PROD[配布の本番の入口] --> RF ``` -図に描かない要素: プランの型と解決の控えの型(参照の部品とプランの実行が読み書きに使う)、入口の契約(核が配布の本番の入口の `refs` を読む)、差し込みの解決(消す)。 +図に描かない要素は次のとおりである。 + +| 要素 | 図に描かない理由 | +| --- | --- | +| プランの型と解決の控えの型 | 参照の部品とプランの実行が読み書きに使う | +| 入口の契約 | 核が配布の本番の入口の `refs` を読む | +| 差し込みの解決 | 消す | ### 置き場所 @@ -209,8 +220,12 @@ classDiagram ### 参照の宣言(プランの `refs`) -プランの型の欄 `refs` は、参照の名前 → 宣言の対応である。ステップの文字列の中では `{ref:<名前>}`(版の起点は `{ref:<名前>.base}`)、 -数の欄(`timeout`)では `{"ref": "<名前>"}` で指す。 +プランの型の欄 `refs` は、参照の名前 → 宣言の対応である。ステップからは次の形で指す。 + +| 指す場所 | 書き方 | +| --- | --- | +| ステップの文字列の中 | `{ref:<名前>}`(版の起点は `{ref:<名前>.base}`) | +| 数の欄(`timeout`) | `{"ref": "<名前>"}` | | 種類 | `params` | 解決した値 | `sticky` | 解決できないとき | | --- | --- | --- | --- | --- | @@ -224,12 +239,13 @@ classDiagram `approved`(任意)は承認時の値を持つ参照に付け、`gate-2` を書く。`version` では承認資料の「承認した版」と、`tip` では承認したコミットと `compare_approved` で照らす。承認資料に「承認した版」の行が無い旧い資料では、#2164 の `series_changed`(承認ゲート 2 の通過の後に系列の版の記録が変わった)で照らす。 -**引数で明示した値は参照にしない。** `--version`・`--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` を渡したときは -値のまま書き、解決の記録の出所は「プランの値(明示)」になる(決定 5)。 +**引数で明示した値は参照にしない。** 次の引数を渡したときは値のまま書く。解決の記録の出所は「プランの値(明示)」になる(決定 5)。 +対象の引数は `--version`・`--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` である。 ### 参照にする設定のキー -設定の契約のキーの行の `plan` が、この表を正として持つ(設計の表は契約からコピーした一覧で、食い違えば契約が正)。表は `supervise_lib/decl.py` の `apply_decls` と `sprint_routes.py` が読む項目から作った(2026-11-04 の develop)。 +設定の契約のキーの行の `plan` が、この表を正として持つ。設計の表は契約からコピーした一覧で、食い違えば契約が正である。 +表は `supervise_lib/decl.py` の `apply_decls` と `sprint_routes.py` が読む項目から作った(2026-11-04 の develop)。 | 設定 | キー | ステップのどこに入るか | `plan` | | --- | --- | --- | --- | @@ -250,7 +266,7 @@ classDiagram ### 解決の控え(`<状態ディレクトリ>/refs.json`) -| 鍵 | 型 | 空を許すか | 意味 | +| キー | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | | `kind` / `version` | str / int | 許さない | `refs-store` / 1(柱 P6 の型の版) | | `rows.<名前>.value` | object | 許さない | 解決した値(版なら `{version, base}`) | @@ -266,17 +282,17 @@ classDiagram #2335 の実行の記録の行(`event: "ref"`)の `fields` は次の形である。1 つの参照の解決ごとに 1 行。 -| 鍵 | 型 | 空を許すか | 意味 | +| キー | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | | `step` | str | 許さない | ステップの id。形を決めた設定の照合は `pins` | | `name` / `kind` | str | 許さない | 参照の名前と種類。プランの値は `kind: "literal"` | | `source` | str | 許さない | `ref`(解決した)/ `store`(控えの値)/ `plan:explicit` / `plan:legacy` | -| `value` | any | 許す | 解決した値。秘密の設定は鍵ごと書かない(I9) | +| `value` | any | 許す | 解決した値。秘密の設定はキーごと書かない(I9) | | `outcome` | str | 許さない | `resolved` / `redecided` / `unresolved` / `mismatch` / `match` | | `approved` | object | 許す | 承認時の値(照らしたときだけ) | | `reason` | str | 許す | 解決できない・食い違う・決め直した理由 | -実行の記録の `start` 行の `fields` に `plan` と `plan_digest` を足す。キューの done の項目にも `plan_digest` を足す(鍵は足すだけ)。 +実行の記録の `start` 行の `fields` に `plan` と `plan_digest` を足す。キューの done の項目にも `plan_digest` を足す(キーは足すだけ)。 ### CRUD @@ -290,7 +306,8 @@ classDiagram | F6 流し直し | R | — | R(`start`) | — | R・C | | F7 旧い形 | R | R(`version.json`) | C | — | — | -書く相手が 2 つ以上ある機能は F3(控えと記録)で、記録は追記だけのため、控えを書いてから記録を書く順にする(落ちたら次の解決が控えを読み、同じ版の `ref` 行を書き直す)。 +書く相手が 2 つ以上ある機能は F3(控えと記録)である。記録は追記だけのため、控えを書いてから記録を書く順にする。 +落ちたら次の解決が控えを読み、同じ版の `ref` 行を書き直す。 ## 入出力の契約 @@ -307,6 +324,8 @@ classDiagram ## 処理の流れ +### プランを流す順 + ```mermaid sequenceDiagram participant C as キュー・再開 @@ -337,6 +356,8 @@ sequenceDiagram E-->>C: 結果 ``` +### 参照を解決する中身 + `resolve` の中(E5〜E7)は次のとおりである。 ```mermaid @@ -353,11 +374,21 @@ sequenceDiagram F->>R: ref 行を書く(E7) ``` -図に描かない要素: 版の判定と承認したコミット(参照の種類の中で呼ぶ)、プランの型と解決の控えの型(読み書きの型)、プランを組む入口・開発ループ・設定の契約(流す前の E1 で働く)、 -承認資料を書く入口(E2 で働く)、入口の契約と配布の本番の入口(下の 2 つ目の項目)、差し込みの解決(消す)。 +図に描かない要素は次のとおりである。 + +| 要素 | 図に描かない理由 | +| --- | --- | +| 版の判定と承認したコミット | 参照の種類の中で呼ぶ | +| プランの型と解決の控えの型 | 読み書きの型 | +| プランを組む入口・開発ループ・設定の契約 | 流す前の E1 で働く | +| 承認資料を書く入口 | E2 で働く | +| 入口の契約と配布の本番の入口 | 下の「流れの決まり」の 2 つ目の項目 | +| 差し込みの解決 | 消す | + +### 流れの決まり -- **実行条件は E4 で 1 回だけ評価する。** 今の `engine.py` の `and not start` を外し、`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない -- **配布の本番の入口は、入口の契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`release_lib/approval.py` から `lib/approved_commit.py` へ移した `check_approved` を呼ぶ箇所はこの 1 つになる +- **実行条件は E4 で 1 回だけ評価する。** 今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない +- **配布の本番の入口は、入口の契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う @@ -418,8 +449,10 @@ stateDiagram-v2 | --- | --- | --- | --- | | #2344 | 実装する | — | `plugins/ndf/scripts/lib/refs.py`、`plugins/ndf/scripts/lib/ref_kinds/`、`plugins/ndf/scripts/lib/model/`、`plugins/ndf/scripts/lib/approved_commit.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/release_lib/approval.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/contracts/settings/`、`plugins/ndf/contracts/entrypoints/`、`scripts/contracts_lib/model.py`、`docs/glossary/glossary.json`、`docs/glossary.md` | -子の課題 4 件(#2230・#2164・#2349・#2354)は表に載せない。表の行はスプリントの Pull Request で閉じるが、子の課題は本番へ出した版で再現の手順を通してから、 -閉じるか残すかを決める(受け入れ条件 12)。#2349 の 3 つ目の事象(差分の無い実装のプランで `pr` が終了コード 1)は柱 P1(#2337)の結果の契約の問題で、この柱では直さない(要求の「含まない」)。 +子の課題 4 件(#2230・#2164・#2349・#2354)は表に載せない。表の行はスプリントの Pull Request で閉じる。 +子の課題は、本番へ出した版で再現の手順を通してから、閉じるか残すかを決める(受け入れ条件 12)。 +#2349 の 3 つ目の事象は、差分の無い実装のプランで `pr` が終了コード 1 になることである。 +これは柱 P1(#2337)の結果の契約の問題で、この柱では直さない(要求の「含まない」)。 ## 未確認のまま残ること From de0f21740d749a16a10659d33ea9ec87a2967119 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 08:59:24 +0000 Subject: [PATCH 04/39] =?UTF-8?q?Docs:=20=E7=94=A8=E8=AA=9E=E9=9B=86?= =?UTF-8?q?=E3=81=AE=E3=80=8C=E7=89=88=E3=81=AE=E6=B1=BA=E3=82=81=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=8D=E3=82=92=E8=A7=A3=E6=B1=BA=E3=81=AE=E6=8E=A7?= =?UTF-8?q?=E3=81=88=E3=81=B8=E4=B8=8A=E6=9B=B8=E3=81=8D=E3=81=99=E3=82=8B?= =?UTF-8?q?=E5=BD=A2=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2344=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計はプランを書き換えず、差し込み前のプランのコピーを作らない。決め直しは 解決の控えの版の行を上書きし、前の版と理由を履歴に残す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 +- docs/glossary/glossary.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 2eda17961..402702877 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -326,7 +326,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 閉じる予定の参照 | — | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | | 版の差し込み | — | 前の版の NDF が組んだ配布のプランが、具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。プランの型の移行関数が版の参照へ読み替える(#2344) | — | — | — | | 版の記録 | — | 解決の控えのうち、版の参照の行。決めた・確かめた版と、決めた根拠と、決め直しの履歴を持ち、流し直しはこれを読む。前の版の NDF が書いた `version.json` は移行関数でこの行へ読み替える(#2344) | — | — | — | -| 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない | — | — | — | +| 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、次に空いている版を決め、解決の控えの版の行をその版へ上書きし、前の版と理由を控えの履歴に残すこと。プランのファイルは書き換えない。明示の版と、自分の配布のマージの後には行わない(#2344) | — | — | — | | 明示の版 | — | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | — | — | — | | 実行の核 | `run_core` | 入口・ステップが必ず通る共通の実行の仕組み。入力の解決・記録・lease・型の確認・再試行と上限・結果の変換を引き受け、ステップは仕事の中身だけを書く(#2335) | — | — | — | | 契約の一覧 | `contract_registry` | エントリポイントごとの入力(引数・環境変数・.ndf/ の設定のキー・標準入力)・出力・書く状態・終了コードと、受け渡しのファイル・設定・環境変数の書き手と読み手と置き場を持つ、リポジトリで 1 つの宣言 | — | — | `docs/specifications/ndf-entrypoint-contracts.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 5c46a246f..388b8b350 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4804,7 +4804,7 @@ { "term": "版の決め直し", "context": "ndf-workflow", - "meaning": "開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない", + "meaning": "開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、次に空いている版を決め、解決の控えの版の行をその版へ上書きし、前の版と理由を控えの履歴に残すこと。プランのファイルは書き換えない。明示の版と、自分の配布のマージの後には行わない(#2344)", "source": "" }, { From 5c784a0b2b663797cdddd75b1915fae02d82eafb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 14:56:15 +0000 Subject: [PATCH 05/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=81=AE=E3=80=8C=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=8D=E3=82=92=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=AE=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=83=AA=E3=83=9D=E3=82=A4=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=B8=E3=81=9D=E3=82=8D=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 章の並びは設計文書のテンプレートの順のまま変えない。内容は足さない。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design-decisions.md | 8 ++--- issues/issue-2344-design.md | 48 +++++++++++++-------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md index c10a1f679..7a701ba11 100644 --- a/issues/issue-2344-design-decisions.md +++ b/issues/issue-2344-design-decisions.md @@ -24,7 +24,7 @@ ### 決定 2: プランが変わったのが人の編集か解決かを区別できるよう、解決した値はプランのファイルへ書き戻さず、メモリ上のステップにだけ入れる 今のキューは差し込みを埋めたプランでファイルを上書きする。そのため #2349 では、完了の後にプランが変わったのか差し込みが埋まったのかを判定できなかった。 -その結果、完了したプランを流し直した。書き戻さなければ、プランのファイルは組んだ入口と人だけが書く。変わったことがそのまま人の編集を意味する。 +その結果、完了したプランを流し直した。書き戻さなければ、プランのファイルは組んだエントリポイントと人だけが書く。変わったことがそのまま人の編集を意味する。 解決した値は実行の記録の `ref` 行に残り、どの実行がどの値で流したかは記録から追える。 埋めたプランを書き戻し、別に差し込み前のコピー(`version-plan.json`)を持つ形は採らない。コピーとプランの 2 つが正になるためである。 @@ -71,14 +71,14 @@ 根拠: Vision / Value 2(MVV 版 5) -### 決定 6: 承認ゲート 2 で人が見た版と流す版を照らせるよう、承認資料に「承認した版」の行を足し、本番の入口は入口の契約の `refs` で参照の部品を通る +### 決定 6: 承認ゲート 2 で人が見た版と流す版を照らせるよう、承認資料に「承認した版」の行を足し、本番のエントリポイントはエントリポイントの契約の `refs` で参照の部品を通る 今の承認資料は承認したコミットの行だけを持つ。版は、承認ゲート 2 の通過の後に系列の版の記録が変わったか(`series_changed`)でしか照らせない。 承認資料に「承認した版」を書けば、人が見た版と解決した版をそのまま比べられる。照合は承認したコミットと同じく `approved: gate-2` を付けた参照で行い、 食い違えば `gate` で人へ戻す。今の `series_changed` は `stopped` である。承認した値との食い違いは人が判断することなので `gate` にする(受け入れ条件 4)。 -本番の入口はプランからも手動でも呼ばれる。そのため照合を入口の契約の `refs` に置き、核が入口ごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 +本番のエントリポイントはプランからも手動でも呼ばれる。そのため照合をエントリポイントの契約の `refs` に置き、核がエントリポイントごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 -照合をプランのステップの側だけに置く形は採らない。手動で本番の入口を呼んだときに照合が抜けるためである。 +照合をプランのステップの側だけに置く形は採らない。手動で本番のエントリポイントを呼んだときに照合が抜けるためである。 「承認した版」の行が無い旧い承認資料は、`series_changed` の照らし方で読む(次の正式版まで)。 根拠: Value 2 / Value 7(MVV 版 5) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index a4bac32ca..fc9e1fb13 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -9,9 +9,9 @@ ## 適用範囲 - **働く範囲**: NDF の配布物のうち次の 3 つ。配布先のリポジトリでも同じに働く。`factory/` のプランは含まない - - プランを組む入口(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`) - - プランを流す処理(キュー・プランの実行・再開の入口) - - 配布の本番の入口 + - プランを組むエントリポイント(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`) + - プランを流す処理(キュー・プランの実行・再開のエントリポイント) + - 配布の本番のエントリポイント - **プロジェクトごとに違うもの**: 参照の値は各リポジトリの `.ndf/` の設定・origin の版とタグ・GitHub の Pull Request から読む。どの設定のキーを参照にするかは設定の契約(`plugins/ndf/contracts/settings/`)の行が持ち、ai-plugins の値を部品に置かない - **当たるモード**: `standard`。#2335 の柱 P5 で、柱 P6(#2339 データの契約)・P8(#2341 入力の契約)・P3(#2342 持ち主の記録)のマージの後に実装する @@ -44,7 +44,7 @@ | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| プラン | プランを組む入口と人(実行の間は誰も書かない) | プランのファイル | ステップ | 参照の宣言・形を決めた設定・実行条件 | +| プラン | プランを組むエントリポイントと人(実行の間は誰も書かない) | プランのファイル | ステップ | 参照の宣言・形を決めた設定・実行条件 | | 解決の控え | 参照の部品(`lib/refs.py`) | プランの状態ディレクトリ | 控えの行(参照の名前ごと) | 解決した値・出所・決め直しの履歴 | | 実行 | 実行の核(#2335) | 実行の識別子 | 実行の記録の行 | 解決の記録の行・プランのハッシュ | @@ -71,7 +71,7 @@ | # | イベント | 発生元 | 受け手 | #2335 | | --- | --- | --- | --- | --- | -| E1 | プランを参照つきで組んだ | プランを組む入口 | プランのファイル | — | +| E1 | プランを参照つきで組んだ | プランを組むエントリポイント | プランのファイル | — | | E2 | 承認時の値(承認したコミット・承認した版)を承認資料に記録した | 承認ゲート 1・2 の承認(`approved_commit.record_approval`) | 参照の部品(E6) | — | | E3 | 持ち主の記録を取った | 実行の核(柱 P3) | プランの実行 | E4 | | E4 | 実行条件を評価した | プランの実行 | プランの実行(飛ばすなら E5〜E8 を行わずに完了) | E5 | @@ -79,7 +79,7 @@ | E6 | 解決した値を承認時の値と照らした | 参照の部品 | プランの実行(食い違えば `gate`) | E5 | | E7 | 解決の記録を書いた | 参照の部品(`ctx.log("ref", …)`) | 実行の記録 | E5 | | E8 | ステップを流した | プランの実行 | 実行の核 | E7 | -| E9 | 同じプランを流し直し・再開した | 再開の入口(柱 P3)・キュー | プランの実行(ハッシュの照合) | E13 | +| E9 | 同じプランを流し直し・再開した | 再開のエントリポイント(柱 P3)・キュー | プランの実行(ハッシュの照合) | E13 | ### 用語 @@ -98,12 +98,12 @@ | # | 機能 | 誰が使うか | | --- | --- | --- | -| F1 | プランを組む入口が、版・記録の PR・前のプランの結果・設定・予算を参照で書き、形を決めた設定を控える | `new sprint` / `new close`・開発ループ | +| F1 | プランを組むエントリポイントが、版・記録の PR・前のプランの結果・設定・予算を参照で書き、形を決めた設定を控える | `new sprint` / `new close`・開発ループ | | F2 | ステップの直前に参照を解決し、解決の記録を書いてメモリ上のステップへ値を入れる | プランの実行 | | F3 | 版の参照の解決を控えに残し、流し直しで同じ版を使い、#2164 の規則のときだけ決め直す | 配布のプラン | | F4 | 承認時の値と照らし、食い違えば `gate` で返す | 本番の配布(プランからも手動の起動からも) | | F5 | 実行条件を開始のステップによらず先に評価する | プランの実行・再開 | -| F6 | 完了したプランの流し直しをプランのハッシュで判定する | 再開の入口・キュー | +| F6 | 完了したプランの流し直しをプランのハッシュで判定する | 再開のエントリポイント・キュー | | F7 | 旧い形のプラン(差し込み・値を書き込んだもの)を移行関数で読み、値をそのまま流す | 前の版の NDF が組んだプラン | ## 構成要素 @@ -113,24 +113,24 @@ | 参照の部品(`lib/refs.py`) | 参照の宣言の型・種類の表・`resolve(ctx, plan, step)`。種類ごとの解決を遅れて読み、解決の記録と解決の控えを書く。外へ書くのは控えと記録だけ | 新設 | | 参照の種類(`lib/ref_kinds/`) | 種類ごとに 1 モジュール(`version`・`record_pr`・`plan_result`・`setting`・`budget`・`tip`)。値か失敗の理由を返し、終了コードと出力を持たない | 新設(`queue_fill.py` の 5 つの関数と `queue.fill_queue_prs` を移す) | | 版の判定(`lib/release_version.py`) | 次に空いている版・既出の判定・自分の配布のマージの有無(変えない) | 変えない | -| 承認したコミット(`lib/approved_commit.py`) | 「承認した版」の行の読み書きを足す。`release_lib/approval.py` の `check_approved`・`release_pr_allowed` を移し、`tip` の種類と配布の本番の入口が同じ関数を呼ぶ | 変更 | -| 承認資料を書く入口(`release-steps.py approval-facts`) | 承認資料に「承認した版」の行を書く | 変更 | +| 承認したコミット(`lib/approved_commit.py`) | 「承認した版」の行の読み書きを足す。`release_lib/approval.py` の `check_approved`・`release_pr_allowed` を移し、`tip` の種類と配布の本番のエントリポイントが同じ関数を呼ぶ | 変更 | +| 承認資料を書くエントリポイント(`release-steps.py approval-facts`) | 承認資料に「承認した版」の行を書く | 変更 | | プランの型(柱 P6 の `lib/model/` のプラン) | `refs`(参照の宣言)・`pins`(形を決めた設定)の欄を持つ。旧い差し込みを参照へ読み替える移行関数を持つ | 変更(柱 P6 の型に欄を足す) | | 解決の控えの型(`lib/model/` の `refs-store`) | 控えの形と、`version.json` を控えへ読み替える移行関数 | 新設 | | プランの実行(`supervise_lib/engine.py`) | ステップの直前に実行条件 → 形を決めた設定 → 参照の解決の順に呼ぶ。開始のステップで実行条件を飛ばさない | 変更 | | キュー(`supervise_lib/queue.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す) | | 差し込みの解決(`supervise_lib/queue_fill.py`) | — | 削除(`FINISHED`・`named`・`results_named` は `ref_kinds/plan_result.py` へ移す) | | 再開(`supervise_lib/queue_resume.py`・柱 P3 の `supervise.py resume`) | 完了したプランをプランのハッシュで照らし、違えば `gate` で返す | 変更 | -| プランを組む入口(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した値は値のまま書く | 変更 | +| プランを組むエントリポイント(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した値は値のまま書く | 変更 | | 開発ループ(`lib/sprint_loop_cli.py`・`lib/sprint_loop.py`) | `--dev` を任意にし、省いたら `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない | 変更 | | 設定の契約(`plugins/ndf/contracts/settings/`)と読み手(`scripts/contracts_lib/model.py`) | キーの行に `plan`(`ref` / `budget` / `shape`)と `secret` を足す。`supervise.json` の `impl_timeout`・`refactor_budget_minutes` の行を足す | 変更 | -| 入口の契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番の入口の行に `refs`(`tip` の参照)を足す | 変更 | +| エントリポイントの契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番のエントリポイントの行に `refs`(`tip` の参照)を足す | 変更 | ### 要素の関係 ```mermaid graph TD - NEW[プランを組む入口] -->|参照と形を書く| PL[プランのファイル] + NEW[プランを組むエントリポイント] -->|参照と形を書く| PL[プランのファイル] LOOP[開発ループ] --> NEW Q[キュー] --> EN[プランの実行] RS[再開] --> EN @@ -142,8 +142,8 @@ graph TD RK --> SC[設定の契約] RF -->|書く| ST[解決の控え] RF -->|追記| LG[実行の記録] - AF[承認資料を書く入口] --> AC - PROD[配布の本番の入口] --> RF + AF[承認資料を書くエントリポイント] --> AC + PROD[配布の本番のエントリポイント] --> RF ``` 図に描かない要素は次のとおりである。 @@ -151,7 +151,7 @@ graph TD | 要素 | 図に描かない理由 | | --- | --- | | プランの型と解決の控えの型 | 参照の部品とプランの実行が読み書きに使う | -| 入口の契約 | 核が配布の本番の入口の `refs` を読む | +| エントリポイントの契約 | 核が配布の本番のエントリポイントの `refs` を読む | | 差し込みの解決 | 消す | ### 置き場所 @@ -160,7 +160,7 @@ graph TD plugins/ndf/ ├── contracts/ │ ├── settings/*.json # 変更: キーの行に plan・secret -│ └── entrypoints/*.json # 変更: 配布の本番の入口に refs +│ └── entrypoints/*.json # 変更: 配布の本番のエントリポイントに refs └── scripts/ ├── lib/ │ ├── refs.py # 新設 @@ -380,15 +380,15 @@ sequenceDiagram | --- | --- | | 版の判定と承認したコミット | 参照の種類の中で呼ぶ | | プランの型と解決の控えの型 | 読み書きの型 | -| プランを組む入口・開発ループ・設定の契約 | 流す前の E1 で働く | -| 承認資料を書く入口 | E2 で働く | -| 入口の契約と配布の本番の入口 | 下の「流れの決まり」の 2 つ目の項目 | +| プランを組むエントリポイント・開発ループ・設定の契約 | 流す前の E1 で働く | +| 承認資料を書くエントリポイント | E2 で働く | +| エントリポイントの契約と配布の本番のエントリポイント | 下の「流れの決まり」の 2 つ目の項目 | | 差し込みの解決 | 消す | ### 流れの決まり - **実行条件は E4 で 1 回だけ評価する。** 今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない -- **配布の本番の入口は、入口の契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) +- **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う @@ -413,10 +413,10 @@ stateDiagram-v2 | 大項目 | 要求の条件 | 実現方式 | 確かめ方 | | --- | --- | --- | --- | -| 性能・拡張性 | 参照を持たない入口の起動に足す時間は #2335 の上限 150 ms の内側。GitHub と origin はその参照を持つステップだけが読む | 核は入口の契約に `refs` が無ければ参照の部品を読み込まない。種類のモジュールは使う種類だけを読み込む | 参照を持たない入口の `--help` を前後で 10 回ずつ測り、差の中央値を実装の Pull Request に書く。GitHub を呼ぶ関数を差し替え、参照を持たないプランで呼ばれないことをテストで見る | +| 性能・拡張性 | 参照を持たないエントリポイントの起動に足す時間は #2335 の上限 150 ms の内側。GitHub と origin はその参照を持つステップだけが読む | 核はエントリポイントの契約に `refs` が無ければ参照の部品を読み込まない。種類のモジュールは使う種類だけを読み込む | 参照を持たないエントリポイントの `--help` を前後で 10 回ずつ測り、差の中央値を実装の Pull Request に書く。GitHub を呼ぶ関数を差し替え、参照を持たないプランで呼ばれないことをテストで見る | | 可用性 | 解決できない・食い違うときは待ち続けずに返る | 種類のモジュールは待ちを持たず、外部の呼び出しは上限の部品(柱 P4)の 1 回の上限で打ち切る | 解決できない・食い違うテストが `stopped` / `gate` で返る | | 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを実行の識別子つきで書く | `ref` 行の `outcome` に 5 つの値 | 種類ごとのテストで `ref` 行の `outcome` を見る | -| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)と入口の移し(プランを組む入口・開発ループ・設定の契約)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | +| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)とエントリポイントの移し(プランを組むエントリポイント・開発ループ・設定の契約)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | | セキュリティ | 秘密と宣言された入力は値を書かない。C1・C2 を変えない | 設定の契約の `secret` が真のキーは、`ref` 行の `value` を書かず、`pins` は digest だけを持つ | 秘密と宣言したキーを参照にしたテスト | | システム環境 | 版の比較は `versions`(extras)。無い環境では `stopped`・69 | `version` の種類だけが `versions` を読み込み、読めなければ `deps` の判定で返す | extras を外した環境で版の参照を持つプランを流すテスト | @@ -441,7 +441,7 @@ stateDiagram-v2 | 10・F7 | 前の版が組んだプラン(差し込みのもの・値を書き込んだもの)と `version.json` を持つ状態ディレクトリで流れ、`ref` 行の `source` が `plan:legacy` / `store` | 移行関数を外す/旧い差し込みを文字のまま流す | | 11・I9 | 秘密と宣言したキーを参照にすると、記録と結果に値が無く出所の名前だけがある | `secret` を見ずに `value` を書く | | 12 | (手動)子の課題 4 件の再現の手順を本番へ出した版で通し、課題の本文の表に結果を書く | — | -| 13 | 参照を持たない入口とプランの振る舞いと結果が前と同じ。全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | 参照を持たないプランでも参照の部品が GitHub を読む | +| 13 | 参照を持たないエントリポイントとプランの振る舞いと結果が前と同じ。全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | 参照を持たないプランでも参照の部品が GitHub を読む | ## 設計の結果 From 33d5e5511daa6cbdb7533710bb9d7f4c23948c81 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:04:03 +0000 Subject: [PATCH 06/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AE=E7=B5=82=E4=BA=86=E3=82=B3=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=83=BB=E4=B8=8A=E4=BD=8D=E3=81=AE=E6=AC=84=E3=81=AE=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E3=83=BB=E7=89=88=E3=81=AE=E6=B1=BA=E3=82=81=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=83=BB=E6=98=8E=E7=A4=BA=E3=81=AE=E7=89=88=E3=81=AE?= =?UTF-8?q?=E7=B5=8C=E8=B7=AF=E3=82=92=E6=B1=BA=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 承認時の値との食い違いと完了したプランの変更の gate を終了コード 10(EXIT_GATE)にそろえる - 作業場所と branch の参照を作業ツリーの前に resolve_head で解決し、I3 の順序に入れる - 版の決め直しで旧版のステップの記録を superseded へ退避し、新版の先頭から流す手順を決める - --version の明示の版を版の参照の explicit に入れ、既出の判定と承認した版の照合を通す Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design-decisions.md | 10 +++--- issues/issue-2344-design.md | 45 +++++++++++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md index 7a701ba11..9b848894c 100644 --- a/issues/issue-2344-design-decisions.md +++ b/issues/issue-2344-design-decisions.md @@ -61,13 +61,15 @@ 根拠: Mission / Value 6 / Value 7(MVV 版 5) -### 決定 5: 利用者が明示した値を AI が置き換えないよう、引数で渡した版・上限・ブランチは参照にせず値のまま持つ +### 決定 5: 利用者が明示した値を AI が置き換えないよう、引数で渡した上限・ブランチは参照にせず値のまま持ち、明示した版は版の参照の `explicit` に値のまま入れる `--version` などで渡した値は、利用者か conductor がその値で流すと決めたものである。参照にして流す時点の値へ置き換えると、明示した決定を黙って上書きする。 -#2164 でも明示の版は決め直さず、既出なら止める。値のまま持ち、解決の記録には出所「プランの値(明示)」を残す。流した値の出所を追えるようにするためである。 -明示の版の既出の判定は、`version` の種類の `explicit` として今と同じに行う。 +上限とブランチは値のまま持ち、解決の記録には出所「プランの値(明示)」を残す。流した値の出所を追えるようにするためである。 +版だけは、明示の値にも確かめることがある。#2164 でも明示の版は決め直さず、既出なら止める。承認ゲート 2 の後は承認した版とも照らす(決定 6)。 +そこで `--version` の値は版の参照の宣言の `params.explicit` に入れ、参照の部品が値を置き換えずに既出の判定と承認した版との照合を通す。解決の記録の出所は `plan:explicit` である。 -明示の値も参照に寄せ、明示であることを宣言の欄で持つ形は採らない。上限とブランチでは明示の値に確かめることが無く、宣言が増えるだけのためである。 +上限とブランチの明示の値も参照に寄せる形は採らない。上限とブランチでは明示の値に確かめることが無く、宣言が増えるだけのためである。 +明示の版を参照にせずリテラルで持つ形は採らない。既出の判定と承認した版との照合を通る経路が無くなるためである。 根拠: Vision / Value 2(MVV 版 5) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index fc9e1fb13..a4d034811 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -57,12 +57,12 @@ | --- | --- | --- | --- | | I1 | プラン | プランのファイルは参照の解決で書き換わらない。解決した値はメモリ上のステップにだけ入る | 書き込みの経路が無い(解決の部品はプランのパスを書き口として受け取らない) | | I2 | 実行 | ステップに入る値は、解決した参照・プランの値(明示)・プランの値(旧い形)のどれかの出所を持ち、1 値 1 行で解決の記録に残る | 記録へ書けなければ `stopped`(#2335 の I3) | -| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後・ステップを流す直前に行う。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | +| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・作業ツリーを作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | | I4 | 実行 | 解決できない参照を使うステップは流れない。推定の値・空・`0`・`unknown` で埋めない | `stopped`・終了コード 6、`gaps` に `ref:<名前>` と理由 | -| I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 3、`items` に承認時の値と実行時の値 | +| I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 10(`EXIT_GATE`)、`items` に承認時の値と実行時の値 | | I6 | 解決の控え | 版の参照は控えがあれば控えの版を使う。決め直すのは #2164 の規則に当たるときだけで、前の版と理由を控えの `history` と解決の記録に残す | 規則に当たらない決め直しの経路が無い(決め直しは `release_version` の判定だけが起こす) | | I7 | プラン | 形を決めた設定の今の値が、プランが控えた値と違えば、プランの最初のステップから流さない | `stopped`・終了コード 2、`gaps` に設定の名前と、`next` に組み直すコマンド | -| I8 | 実行 | 完了したプランを `--from` なしに流し直さない。完了した実行の記録のプランのハッシュと今のハッシュが違えば流さない | `gate`・終了コード 3、`next` に `--from` で流し直すコマンド | +| I8 | 実行 | 完了したプランを `--from` なしに流し直さない。完了した実行の記録のプランのハッシュと今のハッシュが違えば流さない | `gate`・終了コード 10(`EXIT_GATE`)、`next` に `--from` で流し直すコマンド | | I9 | 実行 | 秘密と宣言された設定の値は、解決の記録と結果に書かれず、出所の名前だけが残る | 書く前に値を落とす(#2335 の I9 と同じ経路) | ### ドメインイベント @@ -121,7 +121,7 @@ | キュー(`supervise_lib/queue.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す) | | 差し込みの解決(`supervise_lib/queue_fill.py`) | — | 削除(`FINISHED`・`named`・`results_named` は `ref_kinds/plan_result.py` へ移す) | | 再開(`supervise_lib/queue_resume.py`・柱 P3 の `supervise.py resume`) | 完了したプランをプランのハッシュで照らし、違えば `gate` で返す | 変更 | -| プランを組むエントリポイント(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した値は値のまま書く | 変更 | +| プランを組むエントリポイント(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した上限とブランチは値のまま、明示した版は版の参照の `explicit` に書く | 変更 | | 開発ループ(`lib/sprint_loop_cli.py`・`lib/sprint_loop.py`) | `--dev` を任意にし、省いたら `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない | 変更 | | 設定の契約(`plugins/ndf/contracts/settings/`)と読み手(`scripts/contracts_lib/model.py`) | キーの行に `plan`(`ref` / `budget` / `shape`)と `secret` を足す。`supervise.json` の `impl_timeout`・`refactor_budget_minutes` の行を足す | 変更 | | エントリポイントの契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番のエントリポイントの行に `refs`(`tip` の参照)を足す | 変更 | @@ -211,7 +211,8 @@ classDiagram RefSpec ..> StoreRow : sticky なら読み書き ``` -- **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`RefFailure.status` は `stopped` か `gate` +- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。形を決めた設定の照合の後、作業ツリーを作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。作業ツリーの準備とその後のステップは、このコピーを使う。版の参照の決め直しを確かめるのはこの解決だけである(下の「流れの決まり」) +- **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` - **`check_pins(ctx, plan) -> RefFailure | None`**: 形を決めた設定を今の値と照らす。プランの最初に流すステップの前に 1 回だけ呼ぶ - **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る - **種類のモジュールの形**: `resolve(ctx, spec, plan) -> Resolution | RefFailure`。GitHub と origin を読む種類は、その種類を使うステップでだけ読み込まれる @@ -225,6 +226,7 @@ classDiagram | 指す場所 | 書き方 | | --- | --- | | ステップの文字列の中 | `{ref:<名前>}`(版の起点は `{ref:<名前>.base}`) | +| プランの上位の欄(`作業場所`・`branch`) | `{ref:<名前>}`。版を省いた配布のプランは `branch` に `release/v{ref:version}` を、`作業場所` に版を含むパスを持つ | | 数の欄(`timeout`) | `{"ref": "<名前>"}` | | 種類 | `params` | 解決した値 | `sticky` | 解決できないとき | @@ -239,8 +241,10 @@ classDiagram `approved`(任意)は承認時の値を持つ参照に付け、`gate-2` を書く。`version` では承認資料の「承認した版」と、`tip` では承認したコミットと `compare_approved` で照らす。承認資料に「承認した版」の行が無い旧い資料では、#2164 の `series_changed`(承認ゲート 2 の通過の後に系列の版の記録が変わった)で照らす。 -**引数で明示した値は参照にしない。** 次の引数を渡したときは値のまま書く。解決の記録の出所は「プランの値(明示)」になる(決定 5)。 -対象の引数は `--version`・`--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` である。 +**引数で明示した上限とブランチは参照にしない。** 次の引数を渡したときは値のまま書く。解決の記録の出所は「プランの値(明示)」になる(決定 5)。 +対象の引数は `--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` である。 + +**`--version` で明示した版は、版の参照の `params.explicit` に値のまま入れる。** 参照の部品は明示の版を置き換えず、版の参照の解決で既出の判定(#2164 の `taken`。既出なら `stopped`・終了コード 6)と、`approved` を付けた参照では承認した版との照合を必ず通す。解決の記録の出所は `plan:explicit`、控えの `how` は `explicit` になる(決定 5)。 ### 参照にする設定のキー @@ -316,8 +320,8 @@ classDiagram | `sprint-state.py loop sprint` | `--dev` を任意にする。渡せば今と同じく `new sprint --version <開発版>` を組む。省けば `--version` を渡さず、周回の `versions` は配布の答え(`settle_release`)で埋まる | | 解決できないときの結果 | ステップを流さず、プランの結果は「止まった」、結果 JSON は `status: "stopped"`・終了コード 6、`gaps` に `ref:<名前>`、`summary` に参照の名前と理由 | | 形を決めた設定が違うときの結果 | `status: "stopped"`・終了コード 2、`gaps` に `pin:<設定>`、`next` に同じ引数で組み直す `supervise.py new` のコマンド | -| 承認時の値と食い違うときの結果 | `status: "gate"`・終了コード 3、`items` に `{kind: "ref-mismatch", name, approved, actual, reason}` | -| 完了したプランが変わったときの結果 | 再開とキューの `--resume` は `status: "gate"`・終了コード 3、`items` に `{kind: "plan-changed", plan, digest_done, digest_now}`、`next` に `supervise.py resume <実行の識別子> --from <プラン>=<ステップ>` | +| 承認時の値と食い違うときの結果 | `status: "gate"`・終了コード 10(`EXIT_GATE`)、`items` に `{kind: "ref-mismatch", name, approved, actual, reason}` | +| 完了したプランが変わったときの結果 | 再開とキューの `--resume` は `status: "gate"`・終了コード 10(`EXIT_GATE`)、`items` に `{kind: "plan-changed", plan, digest_done, digest_now}`、`next` に `supervise.py resume <実行の識別子> --from <プラン>=<ステップ>` | | キューの done | 参照で止まったプランの項目は `result: "止まった"`・`unfilled: true`・`reason`。旧い版の `result: "流さなかった"`・`unfilled: true` の項目も読む | | 承認資料 | 「2. 承認の判断に使うもの」の表に `| 承認した版 | <版> |` の行を足す。承認の記録(`record_approval`)は今と同じ | | 旧い形のプラン | `{release_version}`・`{release_base}`・`{queue_record}`・`{queue_pr:<名>}`・`{queue_state(?):<名>}`・`{queue_prs:<名>}`・`{queue_approved:<名>}`・`QUEUE_PRS` は、プランの型の移行関数が参照へ読み替える。受けるのは次の正式版まで | @@ -341,6 +345,13 @@ sequenceDiagram alt 形を決めた設定が違う E-->>C: stopped・2 end + E->>F: resolve_head(作業場所・branch) + alt 解決できない・食い違う + F-->>E: RefFailure + E-->>C: stopped・6 か gate・10 + end + F-->>E: 値を入れたプランのコピー + E->>E: 作業ツリーを作る(コピーの作業場所・branch) loop ステップごと E->>F: resolve(ステップ) alt 解決できない @@ -348,7 +359,7 @@ sequenceDiagram E-->>C: stopped・6 else 食い違う F-->>E: RefFailure(gate) - E-->>C: gate・3 + E-->>C: gate・10 end F-->>E: 値を入れたステップ E->>E: ステップを流す(E8) @@ -391,6 +402,14 @@ sequenceDiagram - **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う +- **版の決め直しを確かめるのは、1 回の起動で最初の解決(`resolve_head`。上位の欄に版の参照が無ければ最初に版を使うステップの解決)だけである。** その後のステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない +- **決め直したら、旧版の成果物を退避して新版の先頭から流す。** 次の順で行い、プランのファイルは書き換えない + 1. 状態ディレクトリのうち、解決の控え(`refs.json`)・退避先(`superseded/`)・状態の実体のコピー(`plan.json`)を除くファイル(ステップの記録・結果・`work/`)を `superseded/v<旧版>/` へ移す + 2. 解決の控えの `rows.version` を新版で上書きし、`history` に `{from, to, reason, existing, observed, left, at}` を追記する。`left` は旧版の `作業場所`・`branch`・退避先である。旧版の作業ツリーとブランチは消さずに残す(消すのは戻せない操作のため) + 3. `ref` 行を `outcome: "redecided"` で書く + 4. 上位の欄を新版で解決し直し、新しい作業場所と `branch` で作業ツリーを作り、最初のステップから流す。旧版の済んだステップの記録は 1 で退避したため、再利用されない + + 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。開始のステップ(`--from`)を渡した起動で決め直しが要るときは、旧版の済んだステップを前提にできないため流さず、`stopped`・終了コード 6、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す ### 流し直しの判定(E9) @@ -432,11 +451,11 @@ stateDiagram-v2 | 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | | 3・I4 | 記録の PR が無い・origin を読めない・設定のキーが無い・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | | 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | -| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする | -| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない | `and not start` を戻す/参照を実行条件より先に解決する | +| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で最初のステップから流れる | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。版を省いた配布のプランは、作業ツリーを版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/上位の欄を解決せずに作業ツリーを作る | | 7・I8 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | | I7 | 組んだ後に `production_branch` か `release` を変えると最初のステップが流れず、`stopped`・2 と組み直すコマンド | 形を決めた設定を照らさない | -| 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば明示の版 | `--dev` を省いたときに既定の版を入れる | +| 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば版の参照の `explicit` に入り、既出の版なら `stopped`・6 | `--dev` を省いたときに既定の版を入れる/明示の版をリテラルで書いて既出の判定を飛ばす | | 9 | #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件の既存のテストが通る。`supervise_lib/queue_fill.py` が無く、`queue.py` に `fill_` で始まる関数が無い | 差し込みの関数をキューに残して二重に解決する | | 10・F7 | 前の版が組んだプラン(差し込みのもの・値を書き込んだもの)と `version.json` を持つ状態ディレクトリで流れ、`ref` 行の `source` が `plan:legacy` / `store` | 移行関数を外す/旧い差し込みを文字のまま流す | | 11・I9 | 秘密と宣言したキーを参照にすると、記録と結果に値が無く出所の名前だけがある | `secret` を見ずに `value` を書く | From d9f3fd8ddad52e28b46e0989ab9e56c5de9cea8f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:10:25 +0000 Subject: [PATCH 07/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E7=89=88?= =?UTF-8?q?=E3=81=AE=E6=B1=BA=E3=82=81=E7=9B=B4=E3=81=97=E3=82=92=E8=80=90?= =?UTF-8?q?=E4=B9=85=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E5=9B=9E=E5=BE=A9=E3=82=88=E3=82=8A=E5=85=88=E3=81=AB?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=81=97=E3=80=81=E4=B8=96=E4=BB=A3=E3=82=92?= =?UTF-8?q?=E6=8E=A5=E9=A0=AD=E8=BE=9E=E3=81=B8=E5=85=A5=E3=82=8C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 決め直した世代は新しい耐久ワークフローとして先頭から始め、旧版の耐久ステップの記録を再利用しない。 用語集の検査に合わせ、作業ツリーを worktree と書く。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index a4d034811..fbaff4da1 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -57,7 +57,7 @@ | --- | --- | --- | --- | | I1 | プラン | プランのファイルは参照の解決で書き換わらない。解決した値はメモリ上のステップにだけ入る | 書き込みの経路が無い(解決の部品はプランのパスを書き口として受け取らない) | | I2 | 実行 | ステップに入る値は、解決した参照・プランの値(明示)・プランの値(旧い形)のどれかの出所を持ち、1 値 1 行で解決の記録に残る | 記録へ書けなければ `stopped`(#2335 の I3) | -| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・作業ツリーを作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | +| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・worktree を作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | | I4 | 実行 | 解決できない参照を使うステップは流れない。推定の値・空・`0`・`unknown` で埋めない | `stopped`・終了コード 6、`gaps` に `ref:<名前>` と理由 | | I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 10(`EXIT_GATE`)、`items` に承認時の値と実行時の値 | | I6 | 解決の控え | 版の参照は控えがあれば控えの版を使う。決め直すのは #2164 の規則に当たるときだけで、前の版と理由を控えの `history` と解決の記録に残す | 規則に当たらない決め直しの経路が無い(決め直しは `release_version` の判定だけが起こす) | @@ -211,7 +211,7 @@ classDiagram RefSpec ..> StoreRow : sticky なら読み書き ``` -- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。形を決めた設定の照合の後、作業ツリーを作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。作業ツリーの準備とその後のステップは、このコピーを使う。版の参照の決め直しを確かめるのはこの解決だけである(下の「流れの決まり」) +- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。形を決めた設定の照合の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「流れの決まり」) - **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` - **`check_pins(ctx, plan) -> RefFailure | None`**: 形を決めた設定を今の値と照らす。プランの最初に流すステップの前に 1 回だけ呼ぶ - **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る @@ -351,7 +351,7 @@ sequenceDiagram E-->>C: stopped・6 か gate・10 end F-->>E: 値を入れたプランのコピー - E->>E: 作業ツリーを作る(コピーの作業場所・branch) + E->>E: worktree を作る(コピーの作業場所・branch) loop ステップごと E->>F: resolve(ステップ) alt 解決できない @@ -402,14 +402,21 @@ sequenceDiagram - **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う -- **版の決め直しを確かめるのは、1 回の起動で最初の解決(`resolve_head`。上位の欄に版の参照が無ければ最初に版を使うステップの解決)だけである。** その後のステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない +- **版の決め直しを確かめるのは、1 回の起動で耐久ワークフローの接頭辞を決める前の判定(下の「決め直しは、耐久ワークフローの回復より先に判定し」の項目)だけである。** その後の解決とステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない - **決め直したら、旧版の成果物を退避して新版の先頭から流す。** 次の順で行い、プランのファイルは書き換えない 1. 状態ディレクトリのうち、解決の控え(`refs.json`)・退避先(`superseded/`)・状態の実体のコピー(`plan.json`)を除くファイル(ステップの記録・結果・`work/`)を `superseded/v<旧版>/` へ移す - 2. 解決の控えの `rows.version` を新版で上書きし、`history` に `{from, to, reason, existing, observed, left, at}` を追記する。`left` は旧版の `作業場所`・`branch`・退避先である。旧版の作業ツリーとブランチは消さずに残す(消すのは戻せない操作のため) + 2. 解決の控えの `rows.version` を新版で上書きし、`history` に `{from, to, reason, existing, observed, left, at}` を追記する。`left` は旧版の `作業場所`・`branch`・退避先である。旧版の worktree とブランチは消さずに残す(消すのは戻せない操作のため) 3. `ref` 行を `outcome: "redecided"` で書く - 4. 上位の欄を新版で解決し直し、新しい作業場所と `branch` で作業ツリーを作り、最初のステップから流す。旧版の済んだステップの記録は 1 で退避したため、再利用されない + 4. 上位の欄を新版で解決し直し、新しい作業場所と `branch` で worktree を作り、最初のステップから流す。旧版の済んだステップの記録は 1 で退避したため、再利用されない - 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。開始のステップ(`--from`)を渡した起動で決め直しが要るときは、旧版の済んだステップを前提にできないため流さず、`stopped`・終了コード 6、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す + 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。 +- **決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の 1 の退避では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める + 1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない + 2. 控えに版の行があるプランは、接頭辞を決める前に、上の判定(`taken` → `own_released`)を流す。`run` は耐久の記録を開く(`durable.launch`)前、キューはプランを耐久キューへ入れる前である。当たれば上の 1〜3 を行ってから接頭辞を決める。控えの行は、前の起動が実行条件と形を決めた設定の照合を通って版を解決したときにだけできるため、この判定は I3 の順序を崩さない。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである + 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューはプランを入れる前に前の世代の接頭辞の途中の耐久ワークフローを止める + 4. 耐久ワークフローの中の `resolve_head` は、決め直しを起こさない。控えの版をそのまま使う(控えが無いときの最初の決定を除く)。決め直しの経路は 2 の判定の 1 つだけになる + + 2 の判定の後・1〜3 の退避の途中で落ちても、次の起動で 2 の判定が同じ結果を返すか(控えを書く前)、世代の増えた控えから新しい接頭辞で始まる(控えを書いた後)。どちらでも旧版の耐久ステップの記録は再利用されない。開始のステップ(`--from`)を渡した起動で決め直しが要るときは、旧版の済んだステップを前提にできないため流さず、`stopped`・終了コード 6、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す ### 流し直しの判定(E9) @@ -451,8 +458,8 @@ stateDiagram-v2 | 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | | 3・I4 | 記録の PR が無い・origin を読めない・設定のキーが無い・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | | 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | -| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で最初のステップから流れる | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する | -| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。版を省いた配布のプランは、作業ツリーを版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/上位の欄を解決せずに作業ツリーを作る | +| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で、世代の増えた新しい耐久ワークフローとして最初のステップから流れる。決め直した後に落ちて打ち直しても、旧版の耐久ステップの記録を引かない | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する/耐久ワークフローの接頭辞に世代を入れない | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/上位の欄を解決せずに worktree を作る | | 7・I8 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | | I7 | 組んだ後に `production_branch` か `release` を変えると最初のステップが流れず、`stopped`・2 と組み直すコマンド | 形を決めた設定を照らさない | | 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば版の参照の `explicit` に入り、既出の版なら `stopped`・6 | `--dev` を省いたときに既定の版を入れる/明示の版をリテラルで書いて既出の判定を飛ばす | From aa14143618fc231c3e9e4102cd351dc6a5e56384 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 15:15:19 +0000 Subject: [PATCH 08/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E6=B1=BA?= =?UTF-8?q?=E3=82=81=E7=9B=B4=E3=81=97=E3=81=AE=E4=BA=8B=E5=89=8D=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E5=AE=9F=E8=A1=8C=E6=9D=A1=E4=BB=B6=E3=81=A8?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=81=AE=E7=85=A7=E5=90=88=E3=81=AE=E5=BE=8C?= =?UTF-8?q?=E3=81=AB=E7=BD=AE=E3=81=8D=E3=80=81=E3=82=AD=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=AF=E6=B1=BA=E3=82=81=E7=9B=B4=E3=81=97=E3=81=9F=E3=82=89?= =?UTF-8?q?=E6=96=B0=E3=81=97=E3=81=8F=E9=96=8B=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 事前判定は実行条件の評価と形を決めた設定の照合を先に行い、飛ばす・食い違うプランでは版を照会せず退避もしない - キューの接頭辞は耐久ステップ admit_step の中で決まり再開で再生されるため、決め直したら launch_queue を fresh で開く Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index fbaff4da1..cbcb97c4b 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -57,7 +57,7 @@ | --- | --- | --- | --- | | I1 | プラン | プランのファイルは参照の解決で書き換わらない。解決した値はメモリ上のステップにだけ入る | 書き込みの経路が無い(解決の部品はプランのパスを書き口として受け取らない) | | I2 | 実行 | ステップに入る値は、解決した参照・プランの値(明示)・プランの値(旧い形)のどれかの出所を持ち、1 値 1 行で解決の記録に残る | 記録へ書けなければ `stopped`(#2335 の I3) | -| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・worktree を作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | +| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・worktree を作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する。版の決め直しの事前判定も、実行条件の評価と形を決めた設定の照合の後に行い、飛ばす・食い違うプランでは版を照会しない | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | | I4 | 実行 | 解決できない参照を使うステップは流れない。推定の値・空・`0`・`unknown` で埋めない | `stopped`・終了コード 6、`gaps` に `ref:<名前>` と理由 | | I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 10(`EXIT_GATE`)、`items` に承認時の値と実行時の値 | | I6 | 解決の控え | 版の参照は控えがあれば控えの版を使う。決め直すのは #2164 の規則に当たるときだけで、前の版と理由を控えの `history` と解決の記録に残す | 規則に当たらない決め直しの経路が無い(決め直しは `release_version` の判定だけが起こす) | @@ -398,7 +398,7 @@ sequenceDiagram ### 流れの決まり -- **実行条件は E4 で 1 回だけ評価する。** 今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない +- **実行条件を評価する関数は 1 つにする。** 呼ぶのは E4 と、版の決め直しの事前判定(下の「決め直しは、耐久ワークフローの回復より先に判定し」の項目の 2)だけである。今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない - **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う @@ -411,9 +411,14 @@ sequenceDiagram 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。 - **決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の 1 の退避では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める - 1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない - 2. 控えに版の行があるプランは、接頭辞を決める前に、上の判定(`taken` → `own_released`)を流す。`run` は耐久の記録を開く(`durable.launch`)前、キューはプランを耐久キューへ入れる前である。当たれば上の 1〜3 を行ってから接頭辞を決める。控えの行は、前の起動が実行条件と形を決めた設定の照合を通って版を解決したときにだけできるため、この判定は I3 の順序を崩さない。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである - 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューはプランを入れる前に前の世代の接頭辞の途中の耐久ワークフローを止める + 1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらも状態ディレクトリの控えから世代を読んで `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない + 2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動が照合を通ったことは根拠にせず、その起動の実行条件と設定の値で確かめる + 1. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない + 2. 形を決めた設定を照合する(I7 と同じ `check_pins`)。食い違えば、版を照会せず、判定も退避もしない + 3. 上の判定(`taken` → `own_released`)を流す。当たれば上の 1〜3 を行ってから接頭辞を決める + + 飛ばす・食い違うプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 と I7 の照合が同じ結果(完了・`stopped`・終了コード 2)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである + 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューは接頭辞を耐久ステップ `admit_step` の中で決め、キューの再開(`launch_queue` の `keep=None`)ではその出力が再生されて旧世代の接頭辞が使われる。そのため、2 の事前判定で 1 つでも決め直したプランがあれば、`launch_queue` を `fresh` で開く。途中の耐久ワークフローは子ごと止まり、`admit_step` が世代の増えた接頭辞を計算し直す。完了したプランは、完了の記録が接頭辞で引けるため流し直されない。決め直しが無ければ、これまでどおり続きから再開する 4. 耐久ワークフローの中の `resolve_head` は、決め直しを起こさない。控えの版をそのまま使う(控えが無いときの最初の決定を除く)。決め直しの経路は 2 の判定の 1 つだけになる 2 の判定の後・1〜3 の退避の途中で落ちても、次の起動で 2 の判定が同じ結果を返すか(控えを書く前)、世代の増えた控えから新しい接頭辞で始まる(控えを書いた後)。どちらでも旧版の耐久ステップの記録は再利用されない。開始のステップ(`--from`)を渡した起動で決め直しが要るときは、旧版の済んだステップを前提にできないため流さず、`stopped`・終了コード 6、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す @@ -458,8 +463,8 @@ stateDiagram-v2 | 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | | 3・I4 | 記録の PR が無い・origin を読めない・設定のキーが無い・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | | 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | -| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で、世代の増えた新しい耐久ワークフローとして最初のステップから流れる。決め直した後に落ちて打ち直しても、旧版の耐久ステップの記録を引かない | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する/耐久ワークフローの接頭辞に世代を入れない | -| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/上位の欄を解決せずに worktree を作る | +| 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で、世代の増えた新しい耐久ワークフローとして最初のステップから流れる。決め直した後に落ちて打ち直しても、旧版の耐久ステップの記録を引かない。キューを `--resume` で再開して決め直しが起きても、`admit_step` が世代の増えた接頭辞で流す | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する/耐久ワークフローの接頭辞に世代を入れない/キューの再開で `admit_step` の旧い出力を再生する | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。控えに版の行があるプランも、実行条件で飛ぶ・形を決めた設定が食い違う起動では、決め直しの事前判定で版を照会せず、状態ディレクトリを退避しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/事前判定を実行条件か形を決めた設定の照合より先に流す/上位の欄を解決せずに worktree を作る | | 7・I8 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | | I7 | 組んだ後に `production_branch` か `release` を変えると最初のステップが流れず、`stopped`・2 と組み直すコマンド | 形を決めた設定を照らさない | | 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば版の参照の `explicit` に入り、既出の版なら `stopped`・6 | `--dev` を省いたときに既定の版を入れる/明示の版をリテラルで書いて既出の判定を飛ばす | From 2038aa273517a87305c6a2913ebef6e4d34d68da Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 20:48:53 +0000 Subject: [PATCH 09/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AE=E5=86=99=E3=81=97=E3=82=92=E7=BD=AE=E3=81=8F?= =?UTF-8?q?=EF=BC=88=E5=91=A8=E5=9B=9E=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=AE?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=82=8B=E6=8C=87=E7=A4=BA=E3=82=92=E5=86=8D?= =?UTF-8?q?=E9=96=8B=E3=81=A7=E8=A7=A3=E3=81=8D=E3=80=81=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=9F=E6=99=82=E9=96=93=E3=82=92=E7=B5=8C?= =?UTF-8?q?=E9=81=8E=E6=99=82=E9=96=93=E3=81=8B=E3=82=89=E5=A4=96=E3=81=99?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-requirements.md | 153 ++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 issues/issue-2474-requirements.md diff --git a/issues/issue-2474-requirements.md b/issues/issue-2474-requirements.md new file mode 100644 index 000000000..021fbe7af --- /dev/null +++ b/issues/issue-2474-requirements.md @@ -0,0 +1,153 @@ +# #2474: 開発ループ: 周回の途中で止める指示を置くと、再開で解けず、止めていた時間が経過時間に入る + +正は課題の本文(#2474)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> ## 何が起きたか +> +> 開発ループ lfactory2 で、周回の途中に `loop stop lfactory2` を打って止める指示(`.ndf/loop/lfactory2/stop`)を置き、セッションを切った(2026-11-02 22:08 JST)。2026-11-04 15:01 JST に再開した。 +> +> 1. `loop start lfactory2` は `in_lap` を返し、止める指示のファイルを消さなかった(`stop` は止まった記録が無いため、再開の扱い `_resume_user_stop` を通らない) +> 2. 周回 4 を `loop lap lfactory2 --failed` で締めると、境の判定が残ったファイルを読み、`user_stop` で止まった。本当に止めたい理由(プロジェクト MVV が版 4 → 5 に改訂された `mvv_changed`)は出なかった +> 3. 周回 4 の秒に、止めていた約 41 時間が入った(消費の hours が 6.19 → 47.14)。確定仕様の「止まっていた時間は経過時間に入れない」に反し、予算 72 時間の 6 割を止めていた時間で使った +> +> ## 期待 +> +> - 周回の途中の止める指示は、`loop start` の再開で解ける(周回の境の止まりと同じ) +> - 止める指示を置いてから再開までの時間は、経過時間に数えない +> +> ## 再現 +> +> 1. 周回を組んだ状態(進行中)で `sprint-state.py loop stop <名>` を打つ +> 2. 時間を置いて `loop start <名>`(`in_lap` が返る) +> 3. `loop lap <名> --failed` → `user_stop` で止まり、周回の秒に止めていた時間が入る +> +> ## 範囲 +> +> `plugins/ndf/scripts/lib/sprint_loop.py`(`cmd_start` の in_lap の経路)・`sprint_loop_record.py`(`boundary_stop`・周回の秒)。 + +(書き手: 起票者、日付: 2026-11-04。開発ループを回していた AI が、起きたことの記録として書いた本文である。利用者の指示の原文は含まない。「止める指示を置いてセッションを切り、再開した」という操作は利用者のものだが、その操作に添えた利用者の文は本文に引用されていない) + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 周回の途中に置いた止める指示は、`loop start` の再開で解ける(周回の境で止まったときと同じ) | 本文の「期待」の 1 行目。確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の再開の表(`user_stop` はファイルを消して解ける)からの類推 | 受け入れ条件 1・2・3 | +| S2 | 止める指示を置いてから再開までの時間は、ループの予算の経過時間に数えない | 本文の「期待」の 2 行目。確定仕様の不変条件 I8(止まっていた時間は経過時間に入らない) | 受け入れ条件 4・5、前提 1 | +| S3 | 直す場所は `cmd_start` の周回の途中の経路と、境の判定・周回の秒 | 本文の「範囲」 | 直す場所は設計が決める。受け入れ条件は場所を指定しない | +| S4 | 「周回の秒」に止めていた時間が入った | 本文の「何が起きたか」の 3 | 採らない。記録を見ると、周回 4 の秒(計画の行の秒の和)は 0 で、止めていた時間が入ったのはループの予算の経過時間(消費の hours)である(下の「目的」の実測) | + +## 目的 + +周回の途中で開発ループを止めて後から再開したとき、止める指示が再開で解け、止めていた時間がループの予算を使わない状態にする。利用者がセッションを切ってループを休ませても、再開した後に余分な止まりが 1 回挟まらず、予算の残りが休ませる前と同じに保たれる。 + +実際に起きた値(`.ndf/loop/lfactory2/loop.json` の記録、時刻は UTC): + +| 項目 | 値 | +| --- | --- | +| 周回 4 の開始 | 2026-11-02 13:04:20 | +| 止める指示のファイルの中身(置いた時刻) | 2026-11-02 13:08:53 | +| 周回 4 を締めた時刻(`user_stop` の止まりの時刻) | 2026-11-04 06:01:43 | +| 周回 4 の秒(計画の行の秒の和) | 0 | +| 消費の hours(周回 4 の前 → 後) | 6.19 → 47.14 | + +周回 4 の区間(開始から締めるまで、約 40.95 時間)がそのまま経過時間に入った。止める指示の時刻から締めた時刻までの約 40.88 時間は、止まりの記録(`stops`)のどの行にも重ならないため引かれなかった(`user_stop` の行は締めた時刻 06:01:43 に始まり 06:02:11 に解けている)。 + +## 前提 + +- 前提 1: 止まっていた区間の始まりは、止める指示のファイルに書かれた時刻(`loop stop` を打った時刻)とする。止める指示を置いた後もセッションが周回を進めていた場合、その時間も経過時間から外れる。セッションがいつ止まったかを記録する仕組みが今は無く、止める指示の時刻が手に入る最も近い値であるため、この扱いを採る +- 前提 2: 止める指示のファイルの中身が時刻として読めない(空・壊れている)ときは、ファイルの更新時刻を区間の始まりとする +- 前提 3: 止まっていた区間の終わりは、`loop start` で再開した時刻とする +- 前提 4: 周回の途中でない(進行中の周回が無い)ときに `loop start` が止める指示を見つけた場合も、止める指示を解く。`loop start` を打つこと自体が、利用者が再開を求めた印であるため。この場合、止まっていた区間は周回の区間の外にあるため、経過時間は変わらない +- 前提 5: 既に止まっている記録(`status` が `stopped`)の再開は、今の再開の表のとおりに動く。この課題では変えない + +## 対象範囲 + +含む: +- `loop start` が、止まっていない記録(進行中の周回がある・無いの両方)で止める指示を見つけたときに、止める指示を解く振る舞い +- 周回の途中で止める指示を置いてから再開するまでの区間を、止まりの記録へ残し、ループの予算の経過時間から外すこと +- 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の、再開の表と不変条件 I3・I8 の記述を振る舞いに合わせること + +含まない: +- 既存のループの記録(lfactory2 など)の値を直すこと。lfactory2 は `mvv_changed` で止まっており再開しない +- 周回の秒(計画の行の秒の和)の数え方。止めていた時間は入っていない(起票者の推定 S4) +- 止める指示が周回の途中で効く(周回の途中で止まる)ようにすること。止める指示は今のとおり次の周回の境で効く +- 境の判定の順(`user_stop` を `mvv_changed` より先に見ること)を変えること。止める指示が再開で解ければ、次の境で `mvv_changed` が出る +- セッションが止まった時刻を記録する仕組み(前提 1) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 周回を始めた | `loop sprint` | 周回を組めなければ `refused` で止まる(今のとおり) | — | +| E2 | 止める指示を置いた | 利用者の `loop stop` | ループの記録が無ければ断る(今のとおり) | — | +| E3 | セッションが終わった | 利用者がセッションを切る・端末が落ちる | 記録は残らない(前提 1) | E2 の後とは限らない | +| E4 | 再開を求めた | 利用者の `loop start` | 使ってよい条件に当たらなければ断る(今のとおり) | E2 | +| E5 | 止める指示を解いた | E4 で止める指示のファイルがある | ファイルを消せなければ再開を断り、記録を書き換えない | E4 | +| E6 | 止まっていた区間を記録した | E5 で進行中の周回がある | 区間の始まりが読めなければ前提 2 の時刻を使う | E5 | +| E7 | 周回を締めた | `loop lap` | 今のとおり | E1 | +| E8 | 境で止まる条件を判定した | E7 の後・選定の前 | 今のとおり | E5 が先に起きていれば `user_stop` は当たらない | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 開発ループ | スプリントを 1 つ終えるたびに次のスプリントを選んで続けて始める進め方(用語集) | +| 周回 | 開発ループの 1 回分(用語集) | +| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える(用語集)。この文書の「経過時間」は、確定仕様の I8 の「動いていた時間」(終わった周回の区間の和から、止まりの記録と重なる部分を引いたもの。結果の `consumed.hours`)を指す | +| 止まる条件 | 開発ループを止める、機械で判定できる条件(用語集)。止める指示は `user_stop` に当たる | +| ループの記録 | `.ndf/loop/<名>/loop.json`(用語集)。止まりの記録は、その中の解けた止まりの一覧(`stops`)を指す | + +止める指示は、`loop stop` が置くファイル `.ndf/loop/<名>/stop`(中身は置いた時刻)を指す。 + +## 受け入れ条件 + +- [ ] 1. 前提: 進行中の周回があり、止める指示のファイルがある。操作: `loop start <名>`。結果: 終了コード 0、`status` が `in_lap`、止める指示のファイルが消えている、結果に止める指示を解いたことが出る +- [ ] 2. 前提: 1 の再開の後。操作: その周回を `loop lap <名>`(`--failed` の有無を問わない)で締める。結果: 境の判定で `user_stop` に当たらない。ほかの止まる条件(`mvv_changed` など)に当たればそれで止まり、当たらなければ次の周回へ進む +- [ ] 3. 前提: 進行中の周回が無く、止まっていない記録に止める指示のファイルがある。操作: `loop start <名>`。結果: 終了コード 0、`status` が `resumed`、止める指示のファイルが消えている。続く `loop select` が `user_stop` で止まらない +- [ ] 4. 前提: 周回を始めた時刻 T0、止める指示を置いた時刻 T1、再開した時刻 T2、周回を締めた時刻 T3(T0 < T1 < T2 < T3)。結果: 締めた後の経過時間(`consumed.hours`)の、この周回の分が (T1 − T0) + (T3 − T2) になる。T2 − T1 は入らない +- [ ] 5. 4 の止まっていた区間(T1〜T2)が、ループの記録の止まりの記録に、周回の番号・始まり T1・終わり T2 つきで 1 行残る。`laps.md` の周回の行の止まりの欄に `user_stop` が出る +- [ ] 6. 止める指示のファイルの中身が時刻として読めないとき、1 と 4 の振る舞いは、ファイルの更新時刻を T1 として成り立つ +- [ ] 7. 止める指示のファイルが無いときの `loop start` の結果(`in_lap` / `resumed`)と、ループの記録の中身は変わらない +- [ ] 8. 既に止まっている記録(`stopped`)の再開は、止まった理由ごとに今と同じ結果になる。既存のテスト(`plugins/ndf/scripts/tests/test_sprint_loop.py`)が通る +- [ ] 9. 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の再開の表と I3・I8 が、受け入れ条件 1〜5 の振る舞いを書いている + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | `sprint-state.py loop start` の結果に、止める指示を解いたことを示す値が加わる。既存の値は変わらない(互換あり) | +| データ | ループの記録の止まりの記録に、周回の途中で止める指示を解いた行(`user_stop`)が加わる。形は既存の行と同じ。移行は無い | +| 既存の振る舞い | 止まっていない記録の `loop start` が、止める指示のファイルを消すようになる。止まっていた区間が経過時間から外れ、ループの予算の `hours` が今より少なく数えられる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_sprint_loop.py -q`(受け入れ条件 1〜8。時刻は `Env.now` の差し替えで進める) | +| テスト(全体) | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析 | `ruff format --check` と `ruff check`(継続的統合の `lint.yml` と同じ) | +| 手動確認 | 次に開発ループを回すとき、周回の途中で `loop stop` → セッションを切る → `loop start` を通し、止める指示が消え、`laps.md` の経過時間に休ませた時間が入らないことを見る(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 開発ループのコードは `plugins/ndf/scripts/lib/sprint_loop*.py`。外の世界は `Env` を通して呼び、テストで差し替える(`sprint_loop_record.py` の先頭の説明) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | +| テスト戦略 | 単体テストで `cmd_start`・`cmd_lap`・経過時間の計算を、差し替えた `Env` と時刻で確かめる(既存の `test_stop_file_works_at_the_next_boundary_and_resume_removes_it` と同じ階層) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、確定仕様の該当箇所の更新 | +| 確認してから行う | `loop start` の結果の形の変更(承認ゲート 1・2 で見る) | +| 行わない | 既存のループの記録の書き換え、境の判定の順の変更、止める指示を周回の途中で効かせること | + +## 未決 + +無し(決められない点は前提 1〜5 に置いた。前提 1 の扱いは承認ゲート 1 で利用者が確かめる) From f465a94626b00fad8696209b362de775f20fa31b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 20:52:18 +0000 Subject: [PATCH 10/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88=E5=91=A8=E5=9B=9E?= =?UTF-8?q?=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=AB=E7=BD=AE=E3=81=84=E3=81=9F?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=82=8B=E6=8C=87=E7=A4=BA=E3=82=92=E5=86=8D?= =?UTF-8?q?=E9=96=8B=E3=81=A7=E8=A7=A3=E3=81=8D=E3=80=81=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=9F=E6=99=82=E9=96=93=E3=82=92=E4=BA=88?= =?UTF-8?q?=E7=AE=97=E3=81=AE=E7=B5=8C=E9=81=8E=E6=99=82=E9=96=93=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=A4=96=E3=81=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 300 ++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 issues/issue-2474-design.md diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md new file mode 100644 index 000000000..ec725a1eb --- /dev/null +++ b/issues/issue-2474-design.md @@ -0,0 +1,300 @@ +# 開発ループ: 周回の途中に置いた止める指示を再開で解き、止めていた時間を予算の経過時間から外す(#2474) + +## 目的 + +- **今どうなっているか**: 周回の途中で `loop stop` を打ってセッションを切り、後から `loop start` で再開すると、止める指示のファイルが残る。周回を締めた境でそのファイルを読み、`user_stop` で止まり直す。止めていた時間は周回の区間に入ったまま、止まりの記録のどの行にも重ならないため、ループの予算の経過時間に数えられる(lfactory2 の周回 4 で約 41 時間、予算 72 時間の約 6 割) +- **誰に効くか**: 開発ループを使う利用者。周回の途中でループを休ませてセッションを切る使い方をする人 +- **変えると何が成り立つか**: `loop start` の再開で止める指示が解ける。休ませた区間は止まりの記録に 1 行残り、経過時間から外れる。再開の後に余分な止まりが挟まらず、予算の残りが休ませる前と同じに保たれる + +## 適用範囲 + +- **働く範囲**: NDF の `sprint-state.py loop` の副命令(`start`・`lap`・`select`)。開発ループを使う配布先のリポジトリでも同じように働く +- **プロジェクトごとに違うもの**: 無し(足す設定・引数は無い。止める指示のファイルの置き場と中身は今のまま) +- **当たるモード**: 開発ループで回すすべてのモード(`pace: auto`) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| `.ndf/loop/lfactory2/loop.json` の記録(要求の「目的」の表): 周回 4 の開始 2026-11-02 13:04:20、止める指示 13:08:53、締めた時刻 2026-11-04 06:01:43、消費の hours 6.19 → 47.14、`user_stop` の行は 06:01:43〜06:02:11 | 実測 | 止めていた区間が止まりの記録に無く、経過時間から引かれていない | +| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の I8(止まっていた時間は経過時間に入らない)と再開の表(`user_stop` はファイルを消して解ける) | 既存の規則 | 止める指示は再開で解けること、止まっていた区間を経過時間から外すことが、すでに決まった形である | +| `plugins/ndf/scripts/lib/sprint_loop.py` の `cmd_start`(止まっていない記録では `in_lap` / `resumed` を返すだけで、止める指示のファイルを見ない) | 実測(コードの読み) | 止める指示を消すのは、止まった記録の再開(`_resume_user_stop`)の経路だけである | + +要求と受け入れ条件は #2474 の本文にある(コピーは `issues/issue-2474-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +この文書での呼び方は次のとおりにする。 + +| 呼び方 | 意味 | +| --- | --- | +| 止める指示 | `loop stop` が置くファイル `.ndf/loop/<名>/stop`。中身は置いた時刻 | +| 止まりの記録 | ループの記録(`loop.json`)の `stops`。解けた止まりの一覧(追記だけ) | +| 経過時間 | 確定仕様の I8 の「動いていた時間」。終わった周回の区間の和から、止まりの記録と重なる部分を引いたもの(結果の `consumed.hours`) | +| 止まっていない記録 | ループの記録の `status` が `running` のもの(`stopped` でないもの) | +| 休ませた区間 | 止める指示を置いた時刻 T1 から、`loop start` で再開した時刻 T2 まで | + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ・周回・ループの予算・止まる条件・ループの記録・止める指示 | + +1 つのコンテキストに収まる。ほかのコンテキストとの関係は変えない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 開発ループ | `sprint_loop.py` の副命令(記録の読み書きは `sprint_loop_record.py` の関数を通す) | ループの記録(`loop.json`) | 周回(`laps[]`) | 止まり(`stop` / `stops[]` の 1 行)・予算・消費 | + +止める指示のファイルは集約の外にある入力である。置くのは利用者の `loop stop`、消すのは `loop start` だけにする。 +この変更で書き換えるのは開発ループの集約だけで、1 回の `loop start` が書き換える集約も 1 つである。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 開発ループ | 止まっていない記録の再開と、`user_stop` で止まった記録の再開では、`loop start` が終了コード 0 を返した後に止める指示のファイルが無い | テストが落ちる | +| I2 | 開発ループ | 止まっていない記録で進行中の周回があるときに止める指示を解いたら、止まりの記録に `condition: user_stop`・その周回の番号・`at` = T1・`resumed_at` = T2 の行がちょうど 1 行足される。進行中の周回が無いときは行を足さない | テストが落ちる | +| I3 | 開発ループ | 休ませた区間(T1〜T2)は、その周回の経過時間に入らない。周回の分は (T1 − T0) + (T3 − T2) になる | テストが落ちる | +| I4 | 開発ループ | T1 は T2 を超えない。止める指示の中身が時刻として読めないときは、ファイルの更新時刻を T1 にする | テストが落ちる | +| I5 | 開発ループ | 止める指示を消せなかったら、`loop start` は断り、ループの記録を書き換えない | テストが落ちる | +| I6 | 開発ループ | 止める指示のファイルが無いときの `loop start` の結果とループの記録は、今と同じである | 既存のテストが落ちる | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E2 | 止める指示を置いた | 利用者の `loop stop` | `loop start`(E5)・周回の境の判定(E8) | +| E4 | 再開を求めた | 利用者の `loop start` | `cmd_start` | +| E5 | 止める指示を解いた | `cmd_start`(止まっていない記録で止める指示がある) | 結果の `released_stop`・周回の境の判定(`user_stop` に当たらなくなる) | +| E6 | 休ませた区間を記録した | `cmd_start`(E5 で進行中の周回がある) | 経過時間の計算(`active_seconds`)・`laps.md` の描画 | +| E8 | 境で止まる条件を判定した | `loop lap` の後・`loop select` の前(`boundary_stop`) | 止まる条件の判定。E5 が先に起きていれば `user_stop` は当たらない | + +要求の E1(周回を始めた)・E3(セッションが終わった)・E7(周回を締めた)は振る舞いを変えないため、この表に載せない。 +E3 は記録が残らないため受け手を持たない(要求の前提 1)。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 開発ループ | スプリントを 1 つ終えるたびに次のスプリントを選んで続けて始める進め方 | 無し(既存の語をそのまま使う) | +| 周回 | 開発ループの 1 回分 | 無し | +| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える | 無し | +| 止まる条件 | 開発ループを止める、機械で判定できる条件 | 無し | +| ループの記録 | `.ndf/loop/<名>/loop.json` | 無し | + +新しい語は足さない。「止める指示」「休ませた区間」はこの文書の中の呼び方で、用語集へは足さない。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 周回の途中でループを休ませた後、`loop start` で再開すると止める指示が解ける | 開発ループを回す利用者と conductor | +| F2 | 周回の外(進行中の周回が無い)で置いた止める指示も、`loop start` で解ける | 同上 | +| F3 | 休ませた区間が止まりの記録と `laps.md` に残り、ループの予算の経過時間から外れる | 同上 | + +## 構成要素 + +| 要素 | 責務 | 変える / 変えない | +| --- | --- | --- | +| `cmd_start`(`plugins/ndf/scripts/lib/sprint_loop.py`) | 止まっていない記録の経路で、止める指示があれば解く関数を呼び、結果に `released_stop` を載せる | 変える | +| `release_stop_request`(新設。`sprint_loop.py`) | 止める指示の時刻 T1 を読み、ファイルを消し、進行中の周回があれば止まりの記録へ 1 行足す。消せなければ断る結果を返す | 作る | +| `stop_request_time`(新設。`plugins/ndf/scripts/lib/sprint_loop_record.py`) | 止める指示のファイルから T1 を読む(中身 → 読めなければ更新時刻)。T2 を超えれば T2 に揃える | 作る | +| `lap_rows`(`sprint_loop_record.py`) | `laps.md` の周回の行の「止まった理由」に、その周回の止まりを起きた順にすべて並べる | 変える | +| `active_seconds`(`sprint_loop_record.py`) | 止まりの記録の `at`〜`resumed_at` と周回の区間の重なりを引く | 変えない(足した行をそのまま読む) | +| `boundary_stop`(`sprint_loop_record.py`) | 周回の境で止まる条件を判定する(順も変えない) | 変えない | +| `resume_stopped` / `_resume_user_stop`(`sprint_loop.py`) | 止まった記録の再開 | 変えない(要求の前提 5) | +| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` | 再開の表・I3・I8・`start` の契約・ループの記録の置き場の表を振る舞いに合わせる | 変える | + +```mermaid +graph TD + U[利用者の
loop start] --> S[cmd_start] + S -->|止まった記録| R[resume_stopped
今のまま] + S -->|止まっていない記録| L[release_stop_request] + L --> T[stop_request_time] + L -->|周回の途中なら
1 行足す| J[(ループの記録の
止まりの記録)] + L -->|消す| F[(止める指示の
ファイル)] + J --> A[active_seconds
経過時間] + J --> M[lap_rows
laps.md] +``` + +図に含めない要素: 周回の境の判定(`boundary_stop`。変えず、止める指示が無いことを読むだけ)と確定仕様(文書)。 + +### 値の集合へ値を足す影響 + +止まりの記録(`stops`)へ、周回の途中で解いた `user_stop` の行が加わる。行の形(`condition`・`values`・`lap`・`at`・`resumed_at`)は +既存の行と同じである。止まりの記録を読む関数を `grep -rn "stops" plugins/ndf/scripts --include=*.py` で集め、テストを除いた +当たりを 1 つずつ判定した(2026-11-04。`merged_lib/merge.py` と `test_strategy.py` の当たりは別のオブジェクトの `stops` 属性で、 +ループの記録を読まない)。 + +| 読む関数 | 新しい行への当てはまり | 扱い | +| --- | --- | --- | +| `active_seconds` | 当てはまる。`at`〜`resumed_at` と周回の区間の重なりを引くのが、この変更の狙いそのものである | 変えない | +| `failure_streak` | 当てはまる。`same_failure` の行だけを区切りに使い、`user_stop` の行は読み飛ばす | 変えない | +| `lap_rows` | 当てはまらない。周回ごとに止まりを 1 つだけ(後の行で上書き)持つため、休ませた後に同じ周回が別の理由で止まると `user_stop` が消える | 変える(決定 4) | +| `resume_record` | 書く側。止まった記録の再開で行を足す | 変えない | + +## 入出力の契約 + +`sprint-state.py loop start` の結果に 1 つ値を足す。既存の値は変えない。 + +| 場合 | 終了コード | `metrics.status` | 足す値 | +| --- | --- | --- | --- | +| 止まっていない記録・進行中の周回あり・止める指示あり | 0 | `in_lap` | `released_stop: {at, source, lap}`。`summary` に「止める指示を解いた」を足す | +| 止まっていない記録・進行中の周回なし・止める指示あり | 0 | `resumed` | `released_stop: {at, source, lap: null}`。同上 | +| 止める指示が無い | 今のまま | 今のまま | 足さない(キー自体を出さない) | +| 止める指示を消せない | 1 | `refused` | `items` に `{path, reason}`。ループの記録は書かない | +| 止まった記録(`stopped`) | 今のまま | 今のまま | 足さない(要求の前提 5) | + +`released_stop` の値: + +| キー | 型 | 意味 | +| --- | --- | --- | +| `at` | 文字列(ISO 8601、UTC) | 休ませた区間の始まり T1 | +| `source` | `content` / `mtime` | T1 を止める指示の中身から読んだか、更新時刻から読んだか | +| `lap` | 整数 / `null` | 止まりの記録へ行を足した周回の番号。進行中の周回が無ければ `null` | + +止まりの記録へ足す行(形は既存の行と同じ): + +```json +{"condition": "user_stop", "values": {"at": "<止める指示の中身のまま>"}, "lap": 4, "at": "", "resumed_at": ""} +``` + +`values.at` は周回の境の判定(`boundary_stop`)が `user_stop` に書く値と同じく、ファイルの中身をそのまま残す。 +中身が読めなかった場合も、空・壊れた中身を `values.at` に残し、`at` に更新時刻を入れる。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant U as 利用者 + participant S as cmd_start + participant F as 止める指示 + participant R as ループの記録 + U->>S: loop start 名 + S->>R: 読む(排他の中) + alt status が stopped + S->>S: resume_stopped(今のまま) + else 止まっていない記録 + S->>F: ある? + alt 無い + S-->>U: in_lap / resumed(今のまま) + else ある + S->>F: T1 を読む(中身 → 更新時刻) + S->>F: 消す + alt 消せない + S-->>U: 終了コード 1(記録を書かない) + else 消せた + opt 進行中の周回がある + S->>R: 止まりの記録へ 1 行(T1〜T2) + end + S->>R: 書く + S-->>U: in_lap / resumed と released_stop + end + end + end +``` + +`cmd_start` の中の順は、予算の上書き(`apply_budget`)→ 止める指示を解く → 記録を書く、にする。 +止める指示を消すのは記録を書く前である(決定 3)。続く `loop lap` の後の境の判定は今のまま `boundary_stop` を通り、 +止める指示が無いため `user_stop` に当たらず、`mvv_changed` などの次の条件を見る。 + +## 決定の記録 + +### 決定 1: 再開を求めた印を 1 か所で受けるため、止まっていない記録の止める指示を `loop start` で解く + +止まった記録の `user_stop` は、すでに `loop start` で解ける。止まっていない記録で止める指示を残すと、同じ利用者の操作 +(`loop stop` → `loop start`)が、周回の境の前か後かで違う結果になる。`loop start` を打つこと自体を再開の印として扱い、 +どちらの経路でも止める指示を消す。 + +周回の境の判定(`boundary_stop`)の側で「再開より前に置かれた止める指示を読み飛ばす」形は採らない。再開の時刻を別に +持つ必要があり、止める指示のファイルも残り続けるため、`loop status` と利用者の目に止める指示が残って見える。 + +根拠: Value 7 / Vision(MVV 版 5) + +### 決定 2: 休ませた区間を経過時間から外すため、止まりの記録へ既存と同じ形の行を足す + +経過時間の計算(`active_seconds`)は、止まりの記録の `at`〜`resumed_at` と周回の区間の重なりをすでに引く。休ませた区間を +同じ形の 1 行として足せば、計算の側を変えずに I8 が成り立つ。`laps.md` の止まりの欄にも同じ経路で出る。 + +周回に「休ませた秒」の欄を新しく足す形は採らない。経過時間の計算と `laps.md` の両方に読み手を足すことになり、止まりの +記録と同じ意味の値を 2 か所に持つ。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 3: 消せなかったときに記録だけが進まないよう、止める指示を消してから記録を書く + +止める指示を先に消し、消せなければ記録を書かずに終了コード 1 で断る(要求の E5)。記録を先に書いてから消す順では、 +消せなかったときに止まりの記録の行だけが残り、次の境で同じ止める指示が `user_stop` として効き、同じ区間が 2 回扱われる。 + +消した後に記録の書き込みが失敗した場合は、例外がそのまま上がる(今の `write_record` の失敗と同じ扱い)。このとき +休ませた区間は経過時間から外れないが、止める指示は解けており、再開の後に余分な止まりは挟まらない。 + +根拠: Value 1(MVV 版 5) + +### 決定 4: 休ませた事実が後の止まりで消えないよう、`laps.md` の止まりの欄に周回の止まりをすべて並べる + +今の `lap_rows` は周回ごとに止まりを 1 つだけ持ち、後の行で上書きする。休ませた周回が締めた後の境で `mvv_changed` などで +止まると、`user_stop` が欄から消える。止まりの記録の順(起きた順)に、条件を `、` で区切って並べる。止まりが 1 つの周回の +欄は今と同じ文字列になる。 + +`laps.md` へ休ませた区間だけの別の表を足す形は採らない。周回の一覧の 1 つの欄で足りる。 + +根拠: Value 8(MVV 版 5) + +### 決定 5: 時刻の誤りで経過時間を負に数えないよう、T1 を T2 以下に揃える + +止める指示の中身が未来の時刻(手で書いた・時計のずれ)だと、`at` が `resumed_at` より後の行になる。重なりの計算は 0 を +返すが、記録として読めない行が残る。T1 が T2 を超えたら T2 にそろえる(区間の長さは 0)。T1 が周回の開始 T0 より前の +とき(周回を組む前に置いた止める指示)は揃えない。重なりの計算が周回の区間で切るため、引きすぎない。 + +根拠: Value 1(MVV 版 5) + +## テスト設計 + +置き場は `plugins/ndf/scripts/tests/test_sprint_loop.py`。時刻は `Env.now` の差し替えで進める。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | 進行中の周回があり止める指示があるとき、`loop start` が 0・`in_lap`・ファイルなし・`released_stop` ありを返す | 止まっていない記録の経路で止める指示を見ないように戻すと落ちる | +| 受け入れ条件 2 | 1 の後に `loop lap`(`--failed` あり・なし)を締めると、境の判定が `user_stop` に当たらない。MVV を改訂しておけば `mvv_changed` で止まる | ファイルを消さずに `released_stop` だけを返すと落ちる | +| 受け入れ条件 3・I2 の後半 | 進行中の周回が無いとき、`loop start` が 0・`resumed`・ファイルなしを返し、止まりの記録に行が増えず、続く `loop select` が `user_stop` で止まらない | 周回が無いときにも行を足すと落ちる | +| 受け入れ条件 4・I3 | T0 < T1 < T2 < T3 の順に時刻を進めると、`consumed.hours` の周回の分が (T1 − T0) + (T3 − T2) になる | 行の `at` に T2 を書く・`resumed_at` を書かないと落ちる | +| 受け入れ条件 5・I2 の前半 | 止まりの記録に `user_stop`・周回の番号・`at` = T1・`resumed_at` = T2 の行がちょうど 1 行あり、`laps.md` の周回の行の止まりの欄に `user_stop` が出る | 行を 2 回足す・周回の番号を書かないと落ちる | +| 決定 4 | 休ませた周回がその後 `mvv_changed` で止まっても、`laps.md` の欄に `user_stop` と `mvv_changed` の両方が出る | 後の止まりで上書きする今の形に戻すと落ちる | +| 受け入れ条件 6・I4 | 止める指示の中身が空・時刻でないとき、ファイルの更新時刻が T1 になり、`source` が `mtime` になる。中身が T2 より後の時刻なら `at` が T2 になる | 中身が読めないときに T2 を使う・揃えずに未来の時刻を残すと落ちる | +| I5 | 止める指示を消せない(消す関数を失敗させる)とき、終了コード 1 で、ループの記録の中身が呼ぶ前と同じ | 記録を先に書いてから消す順にすると落ちる | +| 受け入れ条件 7・I6 | 止める指示が無いとき、`loop start` の結果に `released_stop` が無く、`in_lap` / `resumed` とループの記録が今と同じ | ファイルの有無を見ずに行を足すと落ちる | +| 受け入れ条件 8 | 既存の `test_resume_stopped_*` と `test_stop_file_works_at_the_next_boundary_and_resume_removes_it` が通る | 止まった記録の再開の経路を変えると落ちる | +| 受け入れ条件 9 | 確定仕様の再開の表・I3・I8・`start` の契約が振る舞いを書いている。文言を照合するテストは書かず、コードレビューで読む | — | + +## 確定仕様の直し方 + +`docs/specifications/ndf-workflow-development-loop.md` を次のとおりに直す(受け入れ条件 9)。 + +| 箇所 | 直す内容 | +| --- | --- | +| 常に成り立つ条件の I3 | 止める指示は周回の境でだけ効く、を残し、「止まっていない記録の `loop start` は止める指示を消し、進行中の周回があれば休ませた区間(止める指示の時刻〜再開)を `stops` へ `user_stop` の行として足す」を加える | +| 常に成り立つ条件の I8 | 引く区間に、周回の途中で解いた止める指示の行(I3)が含まれることを書く | +| 副命令の表の `start` | 出力に `released_stop`(`{at, source, lap}`。止める指示を解いたときだけ)を足す。止める指示を消せないときの 1 を足す | +| 再開の表 | 「止まっていない記録に止める指示がある」の行を足す(ファイルを消し、周回の途中なら区間を `stops` へ足す。`in_lap` / `resumed` のまま) | +| ループの記録の置き場の表 | `stop` の行を「`loop stop` が置き、`loop start` で消す」にする | +| `stop` / `stops` の形の段落 | 周回の途中で解いた行も同じ形であることを書く | +| テストの表 | 受け入れ条件 1〜6 の縛りを 1 行足す | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2474 | 実装する | — | `plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_record.py`、`plugins/ndf/scripts/tests/test_sprint_loop.py`、`docs/specifications/ndf-workflow-development-loop.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 止める指示を置いた後もセッションが周回を進めていた時間 | 要求の前提 1 のとおり、その時間も経過時間から外れる。セッションが止まった時刻を記録する仕組みが無いため、実際に動いていた時間との差は測れない。承認ゲート 1 で利用者が前提 1 を確かめる | +| 消した後の記録の書き込みの失敗 | 決定 3 のとおり、休ませた区間が経過時間に残る。発生の頻度は未確認(今の `write_record` の失敗と同じ扱い) | +| `loop stop` と `loop start` の取り合い | `loop stop` は排他を取らない。`loop start` が止める指示を読んで消す間に別の `loop stop` が書くと、後の指示も消える。同時に打つ使い方は確認していない | +| リリース後の実機確認 | 次に開発ループを回すとき、周回の途中で `loop stop` → セッションを切る → `loop start` を通し、`laps.md` の経過時間に休ませた時間が入らないことを見る(要求の検証手段) | From 981917d2b325d3d2e0ccc07a534ca47ee9b86ea7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 20:54:46 +0000 Subject: [PATCH 11/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E6=9C=80=E9=95=B7=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=81=E7=9B=AE=E7=9A=84=E3=81=AE=E7=AB=A0=E3=81=AE?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=90=8D=E3=81=AB=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E6=B7=BB=E3=81=88=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 章の順は設計文書の雛形どおりで並べ替えは無い。内容は足していない。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md index ec725a1eb..8cda1b3de 100644 --- a/issues/issue-2474-design.md +++ b/issues/issue-2474-design.md @@ -2,7 +2,7 @@ ## 目的 -- **今どうなっているか**: 周回の途中で `loop stop` を打ってセッションを切り、後から `loop start` で再開すると、止める指示のファイルが残る。周回を締めた境でそのファイルを読み、`user_stop` で止まり直す。止めていた時間は周回の区間に入ったまま、止まりの記録のどの行にも重ならないため、ループの予算の経過時間に数えられる(lfactory2 の周回 4 で約 41 時間、予算 72 時間の約 6 割) +- **今どうなっているか**: 周回の途中で `loop stop` を打ってセッションを切り、後から `loop start` で再開すると、止める指示のファイルが残る。周回を締めた境でそのファイルを読み、利用者が止めたという条件(`user_stop`)で止まり直す。止めていた時間は周回の区間に入ったまま、止まりの記録のどの行にも重ならないため、ループの予算の経過時間に数えられる(lfactory2 の周回 4 で約 41 時間、予算 72 時間の約 6 割) - **誰に効くか**: 開発ループを使う利用者。周回の途中でループを休ませてセッションを切る使い方をする人 - **変えると何が成り立つか**: `loop start` の再開で止める指示が解ける。休ませた区間は止まりの記録に 1 行残り、経過時間から外れる。再開の後に余分な止まりが挟まらず、予算の残りが休ませる前と同じに保たれる @@ -125,9 +125,9 @@ graph TD ### 値の集合へ値を足す影響 止まりの記録(`stops`)へ、周回の途中で解いた `user_stop` の行が加わる。行の形(`condition`・`values`・`lap`・`at`・`resumed_at`)は -既存の行と同じである。止まりの記録を読む関数を `grep -rn "stops" plugins/ndf/scripts --include=*.py` で集め、テストを除いた -当たりを 1 つずつ判定した(2026-11-04。`merged_lib/merge.py` と `test_strategy.py` の当たりは別のオブジェクトの `stops` 属性で、 -ループの記録を読まない)。 +既存の行と同じである。止まりの記録を読む関数は `grep -rn "stops" plugins/ndf/scripts --include=*.py` で集めた(2026-11-04)。 +テストを除いた当たりを 1 つずつ判定した結果が次の表である。`merged_lib/merge.py` と `test_strategy.py` の当たりは +別のオブジェクトの `stops` 属性で、ループの記録を読まない。 | 読む関数 | 新しい行への当てはまり | 扱い | | --- | --- | --- | From 4882b6e75636ecdb156fc1cc25a4851c0f024e78 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 21:06:41 +0000 Subject: [PATCH 12/39] =?UTF-8?q?docs(design):=20=E5=91=A8=E5=9B=9E?= =?UTF-8?q?=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=A7=E6=AD=A2=E3=81=BE=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E8=A8=98=E9=8C=B2=E3=81=AE=E5=86=8D=E9=96=8B=E3=81=AB?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=82=8B=E6=8C=87=E7=A4=BA=E3=81=8C=E6=AE=8B?= =?UTF-8?q?=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92=E6=98=8E=E8=A8=98=EF=BC=88?= =?UTF-8?q?#2474=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md index 8cda1b3de..0bd57790b 100644 --- a/issues/issue-2474-design.md +++ b/issues/issue-2474-design.md @@ -105,7 +105,7 @@ E3 は記録が残らないため受け手を持たない(要求の前提 1) | `lap_rows`(`sprint_loop_record.py`) | `laps.md` の周回の行の「止まった理由」に、その周回の止まりを起きた順にすべて並べる | 変える | | `active_seconds`(`sprint_loop_record.py`) | 止まりの記録の `at`〜`resumed_at` と周回の区間の重なりを引く | 変えない(足した行をそのまま読む) | | `boundary_stop`(`sprint_loop_record.py`) | 周回の境で止まる条件を判定する(順も変えない) | 変えない | -| `resume_stopped` / `_resume_user_stop`(`sprint_loop.py`) | 止まった記録の再開 | 変えない(要求の前提 5) | +| `resume_stopped` / `_resume_user_stop`(`sprint_loop.py`) | 止まった記録の再開 | 変えない(要求の前提 5)。承認ゲート 2 の差し戻し(`gate2_returned`)と周回を組めなかった止まり(`refused`)の再開は、止める指示のファイルを消さない(「未確認のまま残ること」) | | 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` | 再開の表・I3・I8・`start` の契約・ループの記録の置き場の表を振る舞いに合わせる | 変える | ```mermaid @@ -209,6 +209,9 @@ sequenceDiagram (`loop stop` → `loop start`)が、周回の境の前か後かで違う結果になる。`loop start` を打つこと自体を再開の印として扱い、 どちらの経路でも止める指示を消す。 +この決定が揃えるのは、止まっていない記録の再開と `user_stop` で止まった記録の再開の 2 つである。周回の途中で +`gate2_returned`・`refused` で止まった記録の再開は、要求の前提 5 のとおり変えないため、止める指示が残る(「未確認のまま残ること」)。 + 周回の境の判定(`boundary_stop`)の側で「再開より前に置かれた止める指示を読み飛ばす」形は採らない。再開の時刻を別に 持つ必要があり、止める指示のファイルも残り続けるため、`loop status` と利用者の目に止める指示が残って見える。 @@ -297,4 +300,5 @@ sequenceDiagram | 止める指示を置いた後もセッションが周回を進めていた時間 | 要求の前提 1 のとおり、その時間も経過時間から外れる。セッションが止まった時刻を記録する仕組みが無いため、実際に動いていた時間との差は測れない。承認ゲート 1 で利用者が前提 1 を確かめる | | 消した後の記録の書き込みの失敗 | 決定 3 のとおり、休ませた区間が経過時間に残る。発生の頻度は未確認(今の `write_record` の失敗と同じ扱い) | | `loop stop` と `loop start` の取り合い | `loop stop` は排他を取らない。`loop start` が止める指示を読んで消す間に別の `loop stop` が書くと、後の指示も消える。同時に打つ使い方は確認していない | +| 周回の途中で止まった記録の再開に残る止める指示 | 周回の途中で `loop stop` を置いた後に、承認ゲート 2 の差し戻し(`gate2_returned`)か周回を組めなかった止まり(`refused`)で止まると、その記録の再開(`resume_stopped`)は止める指示のファイルを消さない。再開の後の周回の境で `user_stop` が出直し、止める指示を置いた時刻 T1 から止まった時刻までが経過時間に残る。#2474 と同じ症状がこの経路に残る。直すには止まった記録の再開を変える必要があり、要求の前提 5 と受け入れ条件 8 に触れるため、この設計では直さない。直すかは課題 #2474 の本文で決める | | リリース後の実機確認 | 次に開発ループを回すとき、周回の途中で `loop stop` → セッションを切る → `loop start` を通し、`laps.md` の経過時間に休ませた時間が入らないことを見る(要求の検証手段) | From 651b9cd677741d4c37da500d3c1ee9770621a042 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:21:44 +0000 Subject: [PATCH 13/39] =?UTF-8?q?=E5=A5=91=E7=B4=84=E3=82=92=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=81=8B=E3=82=89=E8=B5=B7=E3=81=93=E3=81=97=E3=81=9F?= =?UTF-8?q?=E9=85=8D=E5=88=97=E3=81=AE=E5=BD=A2=E3=81=B8=E6=9B=B8=E3=81=8D?= =?UTF-8?q?=E6=8F=9B=E3=81=88=E3=80=81=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A1=A8?= =?UTF-8?q?=E3=82=92=E7=94=9F=E6=88=90=E3=81=99=E3=82=8B=EF=BC=88#2384?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用者向けの 34 の操作を、取得と削除は鍵の query の繰り返し、作成・更新・操作は本文の配列にした。 各操作に操作の扱い(x-ndf-access)・要素の鍵(x-ndf-keys)・作った識別子(x-ndf-id)を持たせ、 委任の 3 操作(delegations.yaml)を足した。契約から操作の表(src/generated/operations.ts)を生成し、 形・扱い・記録へ写す鍵を検査で確かめる。 Co-Authored-By: Claude Opus 5.5 --- factory/contracts/openapi/accounts.yaml | 511 +++---- factory/contracts/openapi/delegations.yaml | 150 ++ factory/contracts/openapi/loops.yaml | 499 +++---- factory/contracts/openapi/organizer.yaml | 262 ++-- factory/contracts/openapi/runs.yaml | 1230 ++++++++--------- factory/contracts/package.json | 5 +- factory/contracts/scripts/generate.ts | 11 +- factory/contracts/scripts/operations.ts | 123 ++ factory/contracts/src/generated/operations.ts | 1227 ++++++++++++++++ factory/contracts/src/operations.ts | 83 ++ factory/contracts/tests/operations.test.ts | 118 ++ factory/package-lock.json | 39 + 12 files changed, 2926 insertions(+), 1332 deletions(-) create mode 100644 factory/contracts/openapi/delegations.yaml create mode 100644 factory/contracts/scripts/operations.ts create mode 100644 factory/contracts/src/generated/operations.ts create mode 100644 factory/contracts/src/operations.ts create mode 100644 factory/contracts/tests/operations.test.ts diff --git a/factory/contracts/openapi/accounts.yaml b/factory/contracts/openapi/accounts.yaml index 45624838a..98ccc64d9 100644 --- a/factory/contracts/openapi/accounts.yaml +++ b/factory/contracts/openapi/accounts.yaml @@ -1,394 +1,303 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のアカウントの API - version: 0.1.0 + version: "0.1.0" description: >- - 画面向けのアカウント・席・実行の形態の経路(#2170)。どの経路も Cognito の access token を - Authorization: Bearer で要る。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: me - description: ログインした利用者 - - name: accounts - description: アカウント・席・実行の形態 -security: - - cognito: [] + 画面向けのアカウント・席・実行の形態の操作(#2170)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。使える量の判定は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: me, description: ログインした利用者 }, { name: accounts, description: アカウント・席・実行の形態 }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /me: + "/me": get: tags: [me] operationId: getMe summary: 自分の利用者と、席を持つアカウントの一覧 description: 初めての要求では、Free・local の個人のアカウントを 1 つ作る。 + x-ndf-access: read responses: "200": - description: 利用者とアカウントの一覧 - content: - application/json: - schema: - $ref: "#/components/schemas/Me" - "401": - $ref: "#/components/responses/Unauthorized" - "503": - $ref: "#/components/responses/Unavailable" - /teams: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Me" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/teams": post: tags: [accounts] operationId: createTeam summary: チームのアカウントを作る description: 作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。 + x-ndf-access: human + x-ndf-keys: [idempotency_key] + x-ndf-id: account_id requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/CreateTeam" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateTeamItem" } } responses: - "201": - description: 作ったチームのアカウント + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "503": - $ref: "#/components/responses/Unavailable" - /accounts/{account_id}: - parameters: - - $ref: "#/components/parameters/AccountId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts": get: tags: [accounts] operationId: getAccount summary: アカウントの料金プラン・実行の形態・使える量・席 description: 個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。 + x-ndf-access: read + x-ndf-keys: [account_id] + parameters: + - name: account_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: アカウント + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - /accounts/{account_id}/execution-mode: - parameters: - - $ref: "#/components/parameters/AccountId" - put: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + patch: tags: [accounts] operationId: setExecutionMode summary: 実行の形態を変える description: アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。 + x-ndf-access: write + x-ndf-keys: [account_id] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SetExecutionMode" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/SetExecutionModeItem" } responses: "200": - description: 変えた後のアカウント + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/AccountDetail" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/Conflict" - "503": - $ref: "#/components/responses/Unavailable" - /accounts/{account_id}/seats: - parameters: - - $ref: "#/components/parameters/AccountId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/AccountDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/seats": post: tags: [accounts] operationId: addSeat summary: 席を足す description: チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/AddSeat" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/AddSeat" } } responses: - "201": - description: 足した席 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Seat" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "409": - $ref: "#/components/responses/Conflict" - "422": - $ref: "#/components/responses/UserNotFound" - /accounts/{account_id}/seats/{user_id}: - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/UserId" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } patch: tags: [accounts] operationId: changeSeat summary: 席の種類を変える description: チームの持ち主だけが変えられる。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/ChangeSeat" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ChangeSeatItem" } } responses: "200": - description: 変えた後の席 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Seat" - "400": - $ref: "#/components/responses/InvalidRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/SeatNotFound" - "409": - $ref: "#/components/responses/Conflict" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Seat" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } delete: tags: [accounts] operationId: removeSeat summary: 席を外す description: チームの持ち主だけが外せる。持ち主自身の席は外せない。 + x-ndf-access: human + x-ndf-keys: [user_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: user_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: - "204": - description: 外した - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/SeatNotFound" - "409": - $ref: "#/components/responses/Conflict" + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, bearerFormat: JWT, description: Cognito の利用者プールの access token } + delegation: type: http scheme: bearer - bearerFormat: JWT - description: Cognito の利用者プールの access token - parameters: - AccountId: - name: account_id - in: path - required: true - description: アカウントの ID - schema: - type: string - UserId: - name: user_id - in: path - required: true - description: 利用者の ID(Cognito の sub) - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: - InvalidRequest: - description: 要求の形が正しくない(invalid_request) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Unauthorized: - description: token が無い・壊れている・期限切れ・別の利用者プールかクライアントのもの(unauthorized) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Forbidden: + E400: description: >- - 席も持ち主でもない・アカウントが無い・持ち主の操作を持ち主でない者が求めた(forbidden)。 - アカウントの有無は出さない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - SeatNotFound: - description: 席が無い(seat_not_found)。持ち主と確かめた後だけ返す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Conflict: + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E403: description: >- - not_a_team(個人のアカウントへの席の操作)・seat_exists・owner_seat_protected・ - managed_not_allowed_on_free - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - UserNotFound: - description: 指した利用者がまだログインしていない(user_not_found) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - Unavailable: - description: 保存に失敗した(account_unavailable)。時間をおいてやり直す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: - Plan: - type: string - enum: [free, pro, max, team] - SeatType: - type: string - enum: [standard, premium] + Plan: { type: string, enum: [free, pro, max, team] } + SeatType: { type: string, enum: [standard, premium] } ExecutionMode: type: string description: local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock) enum: [local, byok, managed] - Idp: - type: string - enum: [google, github] + Idp: { type: string, enum: [google, github] } AccountSummary: type: object required: [account_id, kind, name, plan, role, seat_type, execution_mode] properties: - account_id: - type: string - kind: - type: string - enum: [personal, team] - name: - type: [string, "null"] - description: チームの名前。個人のアカウントは null - plan: - $ref: "#/components/schemas/Plan" - role: - type: string - enum: [owner, member] + account_id: { type: string } + kind: { type: string, enum: [personal, team] } + name: { type: [string, "null"], description: チームの名前。個人のアカウントは null } + plan: { $ref: "#/components/schemas/Plan" } + role: { type: string, enum: [owner, member] } seat_type: description: チームのときだけ、要求した利用者の席の種類 - oneOf: - - $ref: "#/components/schemas/SeatType" - - type: "null" - execution_mode: - $ref: "#/components/schemas/ExecutionMode" + oneOf: [{ $ref: "#/components/schemas/SeatType" }, { type: "null" }] + execution_mode: { $ref: "#/components/schemas/ExecutionMode" } Limits: type: object required: [concurrent_sprint_runs, monthly_tokens] properties: - concurrent_sprint_runs: - type: integer - description: 同時に動かせるスプリントの実行の数 - monthly_tokens: - type: [integer, "null"] - description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない) + concurrent_sprint_runs: { type: integer, description: 同時に動かせるスプリントの実行の数 } + monthly_tokens: { type: [integer, "null"], description: 月の token の上限。実行の形態が managed のときだけ数で、ほかは null(掛けない) } Seat: type: object required: [user_id, idp, seat_type, added_at] properties: - user_id: - type: string - idp: - oneOf: - - $ref: "#/components/schemas/Idp" - - type: "null" - seat_type: - $ref: "#/components/schemas/SeatType" - added_at: - type: string - format: date-time + user_id: { type: string } + idp: { oneOf: [{ $ref: "#/components/schemas/Idp" }, { type: "null" }] } + seat_type: { $ref: "#/components/schemas/SeatType" } + added_at: { type: string, format: date-time } AccountDetail: allOf: - - $ref: "#/components/schemas/AccountSummary" + - { $ref: "#/components/schemas/AccountSummary" } - type: object required: [limits, seats] properties: - limits: - description: 表に無い組では null - oneOf: - - $ref: "#/components/schemas/Limits" - - type: "null" + limits: { description: 表に無い組では null, oneOf: [{ $ref: "#/components/schemas/Limits" }, { type: "null" }] } seats: description: チームだけ。個人のアカウントは null - oneOf: - - type: array - items: - $ref: "#/components/schemas/Seat" - - type: "null" + oneOf: [{ type: array, items: { $ref: "#/components/schemas/Seat" } }, { type: "null" }] Me: type: object required: [user_id, idp, personal_account_id, accounts] properties: - user_id: - type: string - idp: - $ref: "#/components/schemas/Idp" - personal_account_id: - type: string - accounts: - type: array - items: - $ref: "#/components/schemas/AccountSummary" - CreateTeam: - type: object - required: [name, seat_type] - properties: - name: - type: string - minLength: 1 - maxLength: 64 - seat_type: - $ref: "#/components/schemas/SeatType" - SetExecutionMode: - type: object - required: [execution_mode] - properties: - execution_mode: - $ref: "#/components/schemas/ExecutionMode" + user_id: { type: string } + idp: { $ref: "#/components/schemas/Idp" } + personal_account_id: { type: string } + accounts: { type: array, items: { $ref: "#/components/schemas/AccountSummary" } } AddSeat: type: object required: [user_id, seat_type] properties: - user_id: - type: string - minLength: 1 - maxLength: 128 - seat_type: - $ref: "#/components/schemas/SeatType" - ChangeSeat: - type: object - required: [seat_type] - properties: - seat_type: - $ref: "#/components/schemas/SeatType" + user_id: { type: string, minLength: 1, maxLength: 128 } + seat_type: { $ref: "#/components/schemas/SeatType" } Error: type: object required: [error] @@ -397,9 +306,43 @@ components: type: object required: [code, message] properties: - code: - type: string - description: 機械の値 - message: - type: string - description: 利用者に出す文 + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + CreateTeamItem: + type: object + required: [idempotency_key, name, seat_type] + properties: + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + name: { type: string, minLength: 1, maxLength: 64 } + seat_type: { $ref: "#/components/schemas/SeatType" } + SetExecutionModeItem: + type: object + required: [account_id, execution_mode] + properties: + account_id: { type: string, minLength: 1 } + execution_mode: { $ref: "#/components/schemas/ExecutionMode" } + ChangeSeatItem: + type: object + required: [user_id, seat_type] + properties: + user_id: { type: string, minLength: 1, maxLength: 128 } + seat_type: { $ref: "#/components/schemas/SeatType" } + Removed: { type: object, required: [removed], properties: { removed: { const: true } } } diff --git a/factory/contracts/openapi/delegations.yaml b/factory/contracts/openapi/delegations.yaml new file mode 100644 index 000000000..67c8fa696 --- /dev/null +++ b/factory/contracts/openapi/delegations.yaml @@ -0,0 +1,150 @@ +openapi: "3.1.0" +info: + title: Neural Development Factory の委任の資格情報の API + version: "0.1.0" + description: >- + 人が画面で委任の資格情報を発行し、一覧で見て、取り消す操作(#2384)。委任は利用者に属し、アカウントに属さない。発行と取り消しは人だけが確定でき、委任の資格情報からは呼べない。一覧は委任の資格情報でも読める。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: delegations, description: エージェントへの委任 }] +security: [{ cognito: [] }, { delegation: [] }] +paths: + /delegations: + get: + tags: [delegations] + operationId: listDelegations + summary: 自分が発行した委任の資格情報の一覧 + description: 新しい順に 100 件まで。値と秘密のハッシュは返さない。 + x-ndf-access: read + responses: + "200": + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Delegation" } } } } + "401": { $ref: "#/components/responses/E401" } + "503": { $ref: "#/components/responses/E503" } + post: + tags: [delegations] + operationId: createDelegation + summary: 委任の資格情報を発行する + description: >- + 値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。 + x-ndf-access: human + x-ndf-keys: [idempotency_key] + x-ndf-id: delegation.credential_id + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/CreateDelegationItem" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/DelegationIssued" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + delete: + tags: [delegations] + operationId: revokeDelegation + summary: 委任の資格情報を取り消す + description: 取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。 + x-ndf-access: human + x-ndf-keys: [credential_id] + parameters: + - name: credential_id + in: query + required: true + style: form + explode: true + description: 要素の鍵。繰り返して 1〜100 個を指す + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Delegation" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } +components: + securitySchemes: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: + type: http + scheme: bearer + description: 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない + responses: + E400: { description: 要求全体の形が違う。どの要素も処理しない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E401: { description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E403: { description: 委任の資格情報で人だけの操作を呼んだ(human_only), content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E503: { description: 保存か操作の記録に失敗した。時間をおいてやり直す, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: { code: { type: string, description: 機械の値 }, message: { type: string, description: 利用者に出す文 }, details: { type: object } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: { code: { type: string }, message: { type: string }, details: { type: object } } + DelegationScope: + type: string + description: read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない + enum: [read, read_write] + CreateDelegationItem: + type: object + required: [client_name, scope, expires_in_seconds, idempotency_key] + additionalProperties: false + properties: + client_name: { type: string, minLength: 1, maxLength: 64, description: 人が付けるクライアントの名前。操作の記録には写さない } + scope: { $ref: "#/components/schemas/DelegationScope" } + expires_in_seconds: { type: integer, minimum: 300, maximum: 3600 } + idempotency_key: { type: string, pattern: "^[0-9A-Za-z-]{1,64}$", description: 作成の冪等の鍵 } + Delegation: + type: object + required: [credential_id, client_name, scope, created_at, expires_at, revoked_at, state] + properties: + credential_id: { type: string } + client_name: { type: string } + scope: { $ref: "#/components/schemas/DelegationScope" } + created_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } + revoked_at: { type: ["string", "null"], format: date-time } + state: { type: string, enum: [active, expired, revoked] } + DelegationIssued: + type: object + required: [delegation, credential] + properties: + delegation: { $ref: "#/components/schemas/Delegation" } + credential: { type: ["string", "null"], description: 委任の資格情報の値。発行の結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は null } diff --git a/factory/contracts/openapi/loops.yaml b/factory/contracts/openapi/loops.yaml index 46b3da320..9c6e6760c 100644 --- a/factory/contracts/openapi/loops.yaml +++ b/factory/contracts/openapi/loops.yaml @@ -1,250 +1,247 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory の開発ループの API - version: 0.1.0 + version: "0.1.0" description: >- - リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る画面向けの経路(#2347)。 - どの経路も Cognito の access token を Authorization: Bearer で要る。ループの accountId が一致しなければ 403、無ければ 404 loop_not_found。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: loops - description: 開発ループ -security: - - cognito: [] + リポジトリの登録ごとに開発ループを始め・止め・予算を変え、周回と人の判断待ちを見る操作(#2347)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ループの accountId が一致しない要素は forbidden、無い要素は loop_not_found(404)の結果になる。人の判断待ちへの答えは人だけが確定できる。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: loops, description: 開発ループ }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/repositories/{repositoryId}/loops: + "/accounts/{accountId}/loops": post: tags: [loops] operationId: startLoop summary: 開発ループを始める description: >- - マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 - 枠で待つときも 201(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると - 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。 + x-ndf-access: write + x-ndf-keys: [idempotency_key] + x-ndf-id: loop_id + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopStartRequest" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopStartItem" } } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStarted" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStarted" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } get: tags: [loops] operationId: listLoops summary: リポジトリの開発ループの一覧 description: 新しい順に 50 件。 + x-ndf-access: read parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } } responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/LoopList" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/loops/{loopId}: - get: - tags: [loops] - operationId: getLoop - summary: 開発ループの詳細 - description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/LoopDetail" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/LoopRow" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } patch: tags: [loops] operationId: updateLoopBudget summary: 開発ループの予算を変える description: 終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopBudgetUpdate" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopBudgetItem" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopRow" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/detail": + get: + tags: [loops] + operationId: getLoop + summary: 開発ループの詳細 + description: 締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。 + x-ndf-access: read + x-ndf-keys: [loop_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: loop_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopRow" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/cancel: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/cancel": post: tags: [loops] operationId: cancelLoop summary: 開発ループを止める description: 動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStateResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/next-lap: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/next-lap": post: tags: [loops] operationId: startNextLap summary: 次の周回を始める(形態 local) - description: 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + description: >- + 次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。 + x-ndf-access: write + x-ndf-keys: [loop_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopRef" } } responses: - "202": - description: 受け付けた + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopStateResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/loops/{loopId}/answers: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopStateResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/loops/answers": post: tags: [loops] operationId: answerLoopPending summary: 人の判断待ちの課題に答える - description: 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/LoopId" + description: >- + 答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。 + x-ndf-access: human + x-ndf-keys: [loop_id, issue] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/LoopAnswer" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/LoopAnswerItem" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/LoopPendingList" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/LoopPendingResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: type: http scheme: bearer - description: Cognito の access token - parameters: - AccountId: - name: accountId - in: path - required: true - schema: - type: string - RepositoryId: - name: repositoryId - in: path - required: true - schema: - type: string - LoopId: - name: loopId - in: path - required: true - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -254,65 +251,50 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } - LoopName: - type: string - pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" + LoopName: { type: string, pattern: "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" } Budget: type: object additionalProperties: false properties: - laps: { type: ["number", "null"], exclusiveMinimum: 0 } - usd: { type: ["number", "null"], exclusiveMinimum: 0 } - hours: { type: ["number", "null"], exclusiveMinimum: 0 } - LoopState: - type: string - enum: [running, between_laps, awaiting_approval, stopped, cancelled] - LoopStartRequest: - type: object - required: [name, milestones] - additionalProperties: false - properties: - name: { $ref: "#/components/schemas/LoopName" } - milestones: - type: array - minItems: 1 - maxItems: 10 - uniqueItems: true - items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" } - budget: { $ref: "#/components/schemas/Budget" } + laps: { type: [number, "null"], exclusiveMinimum: 0 } + usd: { type: [number, "null"], exclusiveMinimum: 0 } + hours: { type: [number, "null"], exclusiveMinimum: 0 } + LoopState: { type: string, enum: [running, between_laps, awaiting_approval, stopped, cancelled] } LoopStarted: type: object required: [loop_id, state] - properties: - loop_id: { type: string } - state: { type: string, enum: [running, between_laps, stopped] } + properties: { loop_id: { type: string }, state: { type: string, enum: [running, between_laps, stopped] } } LoopRow: type: object required: - [loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail] + - loop_id + - name + - milestones + - state + - wait_reason + - laps_closed + - cost_usd + - budget + - budget_remaining + - last_run_at + - stop_condition + - stop_detail properties: loop_id: { type: string } name: { $ref: "#/components/schemas/LoopName" } milestones: { type: array, items: { type: string } } state: { $ref: "#/components/schemas/LoopState" } - wait_reason: { type: ["string", "null"], enum: [slots, legacy_version, user_start, null] } + wait_reason: { type: [string, "null"], enum: [slots, legacy_version, user_start, null] } laps_closed: { type: integer, minimum: 0 } cost_usd: { type: number, minimum: 0 } budget: { $ref: "#/components/schemas/Budget" } budget_remaining: { $ref: "#/components/schemas/Budget" } - last_run_at: { type: ["string", "null"], format: date-time } - stop_condition: { type: ["string", "null"] } - stop_detail: { type: ["string", "null"] } - LoopList: - type: object - required: [loops] - properties: - loops: - type: array - items: { $ref: "#/components/schemas/LoopRow" } + last_run_at: { type: [string, "null"], format: date-time } + stop_condition: { type: [string, "null"] } + stop_detail: { type: [string, "null"] } LoopPending: type: object required: [number, reason, lap, answer] @@ -321,11 +303,11 @@ components: reason: { type: string } lap: { type: integer, minimum: 1 } answer: - type: ["object", "null"] + type: [object, "null"] required: [decision, pr] properties: decision: { type: string, enum: [approved, rejected] } - pr: { type: ["integer", "null"], minimum: 1 } + pr: { type: [integer, "null"], minimum: 1 } LoopDetail: type: object required: [loop, laps, runs, pending] @@ -339,8 +321,8 @@ components: properties: lap: { type: integer, minimum: 1 } issues: { type: array, items: { type: integer } } - result: { type: string, enum: ["完了", "失敗", "人へ戻した"] } - version: { type: ["string", "null"] } + result: { type: string, enum: [完了, 失敗, 人へ戻した] } + version: { type: [string, "null"] } cost_usd: { type: number, minimum: 0 } seconds: { type: number, minimum: 0 } runs: @@ -351,38 +333,73 @@ components: properties: sprint_run_id: { type: string } lap: { type: integer, minimum: 1 } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } - finish_reason: { type: ["string", "null"], enum: [section_limit, usage_limit, null] } - resume_at: { type: ["string", "null"], format: date-time } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } + finish_reason: { type: [string, "null"], enum: [section_limit, usage_limit, null] } + resume_at: { type: [string, "null"], format: date-time } cost_usd: { type: number, minimum: 0 } sections: { type: integer, minimum: 0 } - pending: - type: array - items: { $ref: "#/components/schemas/LoopPending" } - LoopBudgetUpdate: + pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } } + LoopStateResult: type: object - required: [budget] + required: [state] + properties: { state: { $ref: "#/components/schemas/LoopState" } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + LoopStartItem: + type: object + required: [repository_id, idempotency_key, name, milestones] additionalProperties: false properties: + repository_id: { type: integer, minimum: 1 } + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + name: { $ref: "#/components/schemas/LoopName" } + milestones: + type: array + minItems: 1 + maxItems: 10 + uniqueItems: true + items: { type: string, minLength: 1, maxLength: 100, pattern: "^[^,]+$" } budget: { $ref: "#/components/schemas/Budget" } - LoopStateResult: + LoopBudgetItem: type: object - required: [state] - properties: - state: { $ref: "#/components/schemas/LoopState" } - LoopAnswer: + required: [loop_id, budget] + additionalProperties: false + properties: { loop_id: { type: string, minLength: 1 }, budget: { $ref: "#/components/schemas/Budget" } } + LoopRef: + type: object + required: [loop_id] + additionalProperties: false + properties: { loop_id: { type: string, minLength: 1 } } + LoopAnswerItem: type: object - required: [issue, decision] + required: [loop_id, issue, decision] additionalProperties: false properties: + loop_id: { type: string, minLength: 1 } issue: { type: integer, minimum: 1 } decision: { type: string, enum: [approved, rejected] } pr: { type: integer, minimum: 1 } - LoopPendingList: + LoopPendingResult: type: object - required: [pending] + required: [loop_id, pending] properties: - pending: - type: array - items: { $ref: "#/components/schemas/LoopPending" } + loop_id: { type: string } + pending: { type: array, items: { $ref: "#/components/schemas/LoopPending" } } diff --git a/factory/contracts/openapi/organizer.yaml b/factory/contracts/openapi/organizer.yaml index 4c9eae1e7..98f0d80ff 100644 --- a/factory/contracts/openapi/organizer.yaml +++ b/factory/contracts/openapi/organizer.yaml @@ -1,154 +1,126 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のオーガナイザーの API - version: 0.1.0 + version: "0.1.0" description: >- - 同じリポジトリで並行する実行の承認と問いの一覧、まとめた配布の履歴、止まった配布を解く操作(#2439)。 - どの経路も Cognito の access token を Authorization: Bearer で要る。アカウントのリポジトリでなければ 403、登録が無ければ 404。 - 一覧と履歴は読むだけで、承認の依頼の答えを確定する経路はこのファイルに無い(I66)。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE -servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す) -tags: - - name: organizer - description: Factory のオーガナイザー -security: - - cognito: [] + Factory のオーガナイザーの操作(#2439)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。止まった配布を解く操作は人だけが確定できる(オーガナイザーの I66)。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } +servers: [{ url: "https://api.example.com", description: API の入口(値はインフラが FACTORY_API_URL で渡す) }] +tags: [{ name: organizer, description: Factory のオーガナイザー }] +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/repositories/{repositoryId}/approval-requests: + "/accounts/{accountId}/approval-requests": get: tags: [organizer] operationId: listRepositoryApprovalRequests summary: リポジトリの承認と問いの一覧 description: >- - 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 - 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + 同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。 + x-ndf-access: read + x-ndf-keys: [repository_id] parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ApprovalRequestList" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/repositories/{repositoryId}/releases: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ApprovalRequestList" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/releases": get: tags: [organizer] operationId: listRepositoryReleases summary: まとめた配布の履歴 - description: 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + description: >- + 新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。 + x-ndf-access: read parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" - - name: limit - in: query - required: false - schema: { type: integer, minimum: 1, maximum: 100 } + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - { name: repository_id, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 } } responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/ReleaseHistory" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve: + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/releases/resolve": post: tags: [organizer] operationId: resolveStalledRelease summary: 止まった配布を解く description: >- - 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 - 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。 - parameters: - - $ref: "#/components/parameters/AccountId" - - $ref: "#/components/parameters/RepositoryId" - - name: seq - in: path - required: true - schema: { type: integer, minimum: 1 } + 配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。 + x-ndf-access: human + x-ndf-keys: [repository_id, seq] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/ResolveRequest" + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/ResolveItem" } } responses: - "202": - description: 受けた + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ResolveResult" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ResolveResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + delegation: type: http scheme: bearer - description: Cognito の access token - parameters: - AccountId: - name: accountId - in: path - required: true - schema: - type: string - RepositoryId: - name: repositoryId - in: path - required: true - schema: - type: string + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E401: + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E503: + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -158,16 +130,16 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } OrganizerSummary: type: object required: [current_seq, wait_reason, wait_detail] properties: - current_seq: { type: ["integer", "null"], description: 流れているまとめた配布の連番。null は流れていない } - wait_reason: { type: ["string", "null"], enum: [external_release_open, legacy_run, null] } - wait_detail: { type: ["string", "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) } + current_seq: { type: [integer, "null"], description: 流れているまとめた配布の連番。null は流れていない } + wait_reason: { type: [string, "null"], enum: [external_release_open, legacy_run, null] } + wait_detail: { type: [string, "null"], description: 待ちの相手(外の Pull Request の URL・古い版の実行の数) } ReleaseLane: type: object required: [lane, requested, prs] @@ -180,8 +152,8 @@ components: required: [seq, k, sha, lanes, added_prs, choices] properties: seq: { type: integer } - k: { type: ["integer", "null"], description: 最後の提示の番号。提示の前に止まった配布は null } - sha: { type: ["string", "null"] } + k: { type: [integer, "null"], description: 最後の提示の番号。提示の前に止まった配布は null } + sha: { type: [string, "null"] } lanes: { type: array, items: { $ref: "#/components/schemas/ReleaseLane" } } added_prs: { type: array, items: { type: integer } } choices: { type: array, items: { type: string, enum: [continue, reject] } } @@ -193,15 +165,16 @@ components: sprint_run_id: { type: string } loop_id: { type: string } approval_request_id: { type: string } - pr_url: { type: ["string", "null"] } + pr_url: { type: [string, "null"] } number: { type: integer, description: ループの人の判断待ちの課題の番号 } reason: { type: string } created_at: { type: string, format: date-time } release: { $ref: "#/components/schemas/ReleaseSummary" } ApprovalRequestList: type: object - required: [organizer, items] + required: [repository_id, organizer, items] properties: + repository_id: { type: integer } organizer: { $ref: "#/components/schemas/OrganizerSummary" } items: { type: array, items: { $ref: "#/components/schemas/ApprovalRequestItem" } } Presentation: @@ -217,25 +190,25 @@ components: items: type: object required: [lane, requested] - properties: - lane: { type: string } - requested: { type: boolean } + properties: { lane: { type: string }, requested: { type: boolean } } added_prs: { type: array, items: { type: integer } } at: { type: string, format: date-time } ReleaseResult: type: object required: [version, prod_pr_url, sha, prs] properties: - version: { type: ["string", "null"] } - prod_pr_url: { type: ["string", "null"] } - sha: { type: ["string", "null"] } + version: { type: [string, "null"] } + prod_pr_url: { type: [string, "null"] } + sha: { type: [string, "null"] } prs: { type: array, items: { type: integer } } ReleaseLogEntry: type: object required: [at, kind, detail] properties: at: { type: string, format: date-time } - kind: { type: string, enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] } + kind: + type: string + enum: [waited, composed, presented, represented, confirmed, stalled, resolved, released, rejected] detail: { type: object } ReleaseBatch: type: object @@ -245,23 +218,34 @@ components: state: { type: string, enum: [composed, presented, represented, confirmed, stalled, released, rejected] } release_run_id: { type: string } presentations: { type: array, items: { $ref: "#/components/schemas/Presentation" } } - confirmed_k: { type: ["integer", "null"] } + confirmed_k: { type: [integer, "null"] } result: { oneOf: [{ $ref: "#/components/schemas/ReleaseResult" }, { type: "null" }] } log: { type: array, items: { $ref: "#/components/schemas/ReleaseLogEntry" } } - ReleaseHistory: - type: object - required: [items] - properties: - items: { type: array, items: { $ref: "#/components/schemas/ReleaseBatch" } } - ResolveRequest: - type: object - required: [choice] - properties: - choice: { type: string, enum: [continue, reject] } - body: { type: string, maxLength: 4000 } ResolveResult: type: object required: [seq, state] properties: seq: { type: integer } state: { type: string, enum: [composed, represented, confirmed, rejected] } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + ResolveItem: + type: object + required: [repository_id, seq, choice] + properties: + repository_id: { type: integer, minimum: 1 } + seq: { type: integer, minimum: 1 } + choice: { type: string, enum: [continue, reject] } + body: { type: string, maxLength: 4000 } diff --git a/factory/contracts/openapi/runs.yaml b/factory/contracts/openapi/runs.yaml index 9aa7ff265..528694657 100644 --- a/factory/contracts/openapi/runs.yaml +++ b/factory/contracts/openapi/runs.yaml @@ -1,686 +1,622 @@ -openapi: 3.1.0 +openapi: "3.1.0" info: title: Neural Development Factory のスプリントの実行の API - version: 0.1.0 + version: "0.1.0" description: >- - 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の経路と、ランナー向けの経路(#2186)。 - 画面向けは Cognito の access token、ランナー向け(/run-jobs/*)は実行ジョブの token を Authorization: Bearer で要る。 - 再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 - contact: - name: devbasex/ai-plugins - url: https://github.com/devbasex/ai-plugins - license: - name: MIT - url: https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE + 画面向けの取り込み・キーの持ち込み・課題の候補・スプリントの実行の操作と、ランナー向けの経路(#2186)。利用者向けの操作は機能(エンティティと操作)から起こし、取得・作成・更新・削除とも配列で受け渡す(#2384)。Cognito の access token か、人が発行した委任の資格情報を Authorization: Bearer で要る。要素ごとの結果は入力と同じ順に返し、要求全体を断るのは資格情報・操作の扱い・要素の数と重複・経路のアカウント・操作の記録の失敗だけ。ランナー向け(/run-jobs/*)は実行ジョブの token を要り、配列の規則の外にある。再開の要求と形態 local の渡す口は HTTP の経路にせず、factory/api/src/index.ts の関数で呼ぶ。 + contact: { name: devbasex/ai-plugins, url: "https://github.com/devbasex/ai-plugins" } + license: { name: MIT, url: "https://github.com/devbasex/ai-plugins/blob/main/factory/LICENSE" } servers: - - url: https://api.example.com - description: API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin) + - url: "https://api.example.com" + description: >- + API の入口(値はインフラが FACTORY_API_URL で渡す。/run-jobs/* は INGEST_URL と同じ origin) tags: - - name: repositories - description: リポジトリの登録 - - name: credentials - description: キーの持ち込み - - name: sprint-runs - description: 課題の候補とスプリントの実行 - - name: run-jobs - description: ランナー向けの経路 -security: - - cognito: [] + - { name: repositories, description: リポジトリの登録 } + - { name: credentials, description: キーの持ち込み } + - { name: sprint-runs, description: 課題の候補とスプリントの実行 } + - { name: run-jobs, description: ランナー向けの経路 } +security: [{ cognito: [] }, { delegation: [] }] paths: - /accounts/{accountId}/github/install-url: + "/run-jobs/github-token": + post: + tags: [run-jobs] + operationId: postRunJobGitHubToken + summary: 導入の token の渡し直し + description: >- + 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/GitHubToken" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "409": { $ref: "#/components/responses/E409" } + "/run-jobs/state": + post: + tags: [run-jobs] + operationId: postRunJobState + summary: 状態の置き場の署名つき URL + description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。 + security: [{ runJob: [] }] + requestBody: + required: true + content: { "application/json": { schema: { $ref: "#/components/schemas/StateRequest" } } } + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/SignedUrl" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "/run-jobs/approval-requests/{approvalRequestId}/answer": + get: + tags: [run-jobs] + operationId: getRunJobApprovalAnswer + summary: 承認の依頼への人の答え + description: >- + resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、 その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。 + security: [{ runJob: [] }] + parameters: [{ name: approvalRequestId, in: path, required: true, schema: { type: string } }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/ApprovalAnswer" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "409": { $ref: "#/components/responses/E409" } + "/run-jobs/organizer/answers": + get: + tags: [run-jobs] + operationId: getRunJobOrganizerAnswers + summary: 配布の依頼の答えと配布済みの範囲 + description: >- + 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerAnswers" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "/run-jobs/organizer/batch": + get: + tags: [run-jobs] + operationId: getRunJobOrganizerBatch + summary: 配布の実行のまとめた配布の中身 + description: >- + 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。 + security: [{ runJob: [] }] + responses: + "200": + description: 成功 + content: { "application/json": { schema: { $ref: "#/components/schemas/OrganizerBatch" } } } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "404": { $ref: "#/components/responses/E404" } + "/accounts/{accountId}/github/install-url": get: tags: [repositories] operationId: getInstallUrl summary: GitHub App の導入の画面の URL - description: 利用者が自分のリポジトリへ GitHub App を入れる画面の URL を返す。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + description: GitHub App の導入の URL。要素 1 個の配列で返す。 + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Url" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/github/authorize-url: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/github/authorize-url": get: tags: [repositories] operationId: getAuthorizeUrl summary: GitHub App の利用者の認可の URL description: state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Url" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/github/candidates: + description: 条件で集めた値の配列 + content: { "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Url" } } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/github/candidates": post: tags: [repositories] operationId: postRepositoryCandidates summary: 認可の戻りから登録の候補を返す description: 導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: write + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/CandidatesRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/CandidatesRequest" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/RepositoryCandidates" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - /accounts/{accountId}/repositories: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/RepositoryCandidates" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/repositories": get: tags: [repositories] operationId: listRepositories summary: 登録したリポジトリの一覧 description: リポジトリごとの終わりの状態でないスプリントの実行の数を添える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/RepositoryList" - "403": - $ref: "#/components/responses/E403" + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/Repository" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } post: tags: [repositories] operationId: registerRepository summary: リポジトリを登録する description: 候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] + requestBody: + required: true + content: + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/RegisterRequest" } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + patch: + tags: [repositories] + operationId: updateRepository + summary: リポジトリの同時の実行の数を宣言する + description: "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。" + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/RegisterRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/RepositoryUpdateItem" } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Repository" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/model-credential: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Repository" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + delete: + tags: [repositories] + operationId: removeRepository + summary: リポジトリの登録を外す + description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。 + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query + required: true + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } + responses: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/model-credential": get: tags: [credentials] operationId: getModelCredential summary: 持ち込んだキーの有無 description: 登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: read + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/ModelCredential" - "403": - $ref: "#/components/responses/E403" + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/ModelCredential" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } put: tags: [credentials] operationId: putModelCredential summary: キーを保存・差し替える description: Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + x-ndf-access: human + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ModelCredentialInput" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/ModelCredentialInput" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/ModelCredential" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/ModelCredential" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } delete: tags: [credentials] operationId: deleteModelCredential summary: 持ち込んだキーを消す - description: 復元の期間を置かずに消す(決定 14)。無くても 204。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + description: 復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。 + x-ndf-access: human + x-ndf-keys: [] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] responses: - "204": - description: 成功 - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/repositories/{repositoryId}/issue-candidates: + "200": + description: 入力と同じ順・同じ数の要素の結果 + content: + "application/json": + schema: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Removed" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/issue-candidates": post: tags: [sprint-runs] operationId: postIssueCandidates summary: AI が選んだ課題の候補 - description: 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string + description: >- + 棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。 + x-ndf-access: write + x-ndf-keys: [repository_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/IssueCandidatesRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/IssueCandidatesItem" } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/IssueCandidates" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /accounts/{accountId}/sprint-runs: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/IssueCandidates" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs": post: tags: [sprint-runs] operationId: startSprintRun summary: スプリントの実行を始める description: >- - 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 - 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 - 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 - 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string + 確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。 + x-ndf-access: write + x-ndf-keys: [idempotency_key] + x-ndf-id: sprint_run_id + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/StartRequest" + type: array + minItems: 1 + maxItems: 100 + items: { $ref: "#/components/schemas/StartSprintRunItem" } responses: - "201": - description: 成功 + "200": + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SprintRunStarted" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunStarted" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } get: tags: [sprint-runs] operationId: listSprintRuns summary: スプリントの実行の一覧 - description: 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 + description: >- + 新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。 + x-ndf-access: read parameters: - - name: accountId - in: path - required: true - schema: - type: string + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } - name: repository_id in: query required: false - schema: - type: integer - minimum: 1 + description: 省くとアカウントの新しい順の 50 件 + schema: { type: integer, minimum: 1 } responses: "200": - description: 成功 + description: 条件で集めた値の配列 content: - application/json: - schema: - $ref: "#/components/schemas/SprintRunList" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}: + "application/json": { schema: { type: array, items: { $ref: "#/components/schemas/SprintRunRow" } } } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/detail": get: tags: [sprint-runs] operationId: getSprintRunDetail summary: スプリントの詳細 description: >- - フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 - 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の経路で 1 件ずつ読む。 - step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 - 無い実行・アカウントに属さない実行も、他人のアカウントと同じ本文の 403。 + フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [sprint_run_id] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: sprint_run_id + in: query required: true - schema: - type: string + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/SprintRunDetail" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/SprintRunDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/steps": get: tags: [sprint-runs] operationId: getStepDetail summary: 工程の中身 description: >- - 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 - stepKey は事象の idempotency_key(URL の符号化をする)。記録の無い項目は null。 - 無い工程・別の実行の工程も、他人のアカウントと同じ本文の 403。 + 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [step_key] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: sprint_run_id + in: query required: true - schema: - type: string - - name: stepKey - in: path + description: 工程を持つ実行。他人の実行なら要求全体が 403 + schema: { type: string } + - name: step_key + in: query required: true - schema: - type: string + style: form + explode: true + description: "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: string } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/StepDetail" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/repositories/{repositoryId}/backlog: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/StepDetail" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/backlogs": get: tags: [repositories] operationId: getBacklog summary: バックログ description: >- - リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 - 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリも、他人のアカウントと同じ本文の 403。 + リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。 + x-ndf-access: read + x-ndf-keys: [repository_id] parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path + - { name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } } + - name: repository_id + in: query required: true - schema: - type: integer - minimum: 1 + style: form + explode: true + description: "要素の鍵。繰り返して 1〜100 個を指す" + schema: { type: array, minItems: 1, maxItems: 100, items: { type: integer, minimum: 1 } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/Backlog" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "503": - $ref: "#/components/responses/E503" - /accounts/{accountId}/sprint-runs/{sprintRunId}/cancel: + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/Backlog" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } + "/accounts/{accountId}/sprint-runs/cancel": post: tags: [sprint-runs] operationId: cancelSprintRun summary: スプリントの実行を取り消す - description: 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: sprintRunId - in: path - required: true - schema: - type: string - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/CancelResult" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /run-jobs/github-token: - post: - tags: [run-jobs] - operationId: postRunJobGitHubToken - summary: 導入の token の渡し直し - description: 実行ジョブの token で認証し、その実行のリポジトリ 1 つに絞った 1 時間の導入の token を返す(決定 7)。 - security: - - runJob: [] - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/GitHubToken" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "409": - $ref: "#/components/responses/E409" - /run-jobs/state: - post: - tags: [run-jobs] - operationId: postRunJobState - summary: 状態の置き場の署名つき URL - description: その実行の鍵だけの 15 分の URL を返す(決定 8)。get で保存が無ければ 404。 - security: - - runJob: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/StateRequest" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/SignedUrl" - "400": - $ref: "#/components/responses/E400" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - /run-jobs/approval-requests/{approvalRequestId}/answer: - get: - tags: [run-jobs] - operationId: getRunJobApprovalAnswer - summary: 承認の依頼への人の答え description: >- - resume の実行ジョブが、起動の指示の APPROVAL_REQUEST_ID で人の答えを 1 回だけ読む。実行ジョブの token で認証し、 - その実行の依頼でなければ 403、答えがまだ無ければ 409。sprint.json の置き場は返さない - (ランナーが復元した状態から求める)。答えを保存する側の実装は #2173。 - security: - - runJob: [] - parameters: - - name: approvalRequestId - in: path - required: true - schema: { type: string } - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/ApprovalAnswer" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /accounts/{accountId}/repositories/{repositoryId}: - patch: - tags: [repositories] - operationId: updateRepository - summary: リポジトリの同時の実行の数を宣言する - description: 料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string + 動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。 + x-ndf-access: write + x-ndf-keys: [sprint_run_id] + parameters: [{ name: accountId, in: path, required: true, description: アカウントの ID, schema: { type: string } }] requestBody: required: true content: - application/json: - schema: - $ref: "#/components/schemas/RepositoryUpdate" - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/Repository" - "400": - $ref: "#/components/responses/E400" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - delete: - tags: [repositories] - operationId: removeRepository - summary: リポジトリの登録を外す - description: 終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。 - parameters: - - name: accountId - in: path - required: true - schema: - type: string - - name: repositoryId - in: path - required: true - schema: - type: string - responses: - "204": - description: 成功 - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" - "409": - $ref: "#/components/responses/E409" - /run-jobs/organizer/answers: - get: - tags: [run-jobs] - operationId: getRunJobOrganizerAnswers - summary: 配布の依頼の答えと配布済みの範囲 - description: >- - 実行ジョブの token で認証し、そのジョブの実行のレーン(ループならループのすべての実行)の配布の依頼の答えと、 - リポジトリの配布済みの範囲を返す(#2439)。ランナーは NDF の置き場の answers/ と ranges/ へそのまま書く。 - security: - - runJob: [] - responses: - "200": - description: 成功 - content: - application/json: - schema: - $ref: "#/components/schemas/OrganizerAnswers" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - /run-jobs/organizer/batch: - get: - tags: [run-jobs] - operationId: getRunJobOrganizerBatch - summary: 配布の実行のまとめた配布の中身 - description: >- - 配布の実行のジョブの token だけが読める(ほかの実行は 403)。レーンの一覧と依頼、提示、選択肢と、 - このジョブの手(step)を返す。ランナーは NDF の置き場の batch.json へそのまま書く(#2439)。 - security: - - runJob: [] + "application/json": + schema: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/SprintRunRef" } } responses: "200": - description: 成功 + description: 入力と同じ順・同じ数の要素の結果 content: - application/json: + "application/json": schema: - $ref: "#/components/schemas/OrganizerBatch" - "401": - $ref: "#/components/responses/E401" - "403": - $ref: "#/components/responses/E403" - "404": - $ref: "#/components/responses/E404" + type: array + items: + oneOf: + - type: object + required: [ok, value] + properties: { ok: { const: true }, value: { $ref: "#/components/schemas/CancelResult" } } + - { $ref: "#/components/schemas/ElementFailure" } + "400": { $ref: "#/components/responses/E400" } + "401": { $ref: "#/components/responses/E401" } + "403": { $ref: "#/components/responses/E403" } + "503": { $ref: "#/components/responses/E503" } components: securitySchemes: - cognito: - type: http - scheme: bearer - description: Cognito の access token - runJob: + cognito: { type: http, scheme: bearer, description: Cognito の access token } + runJob: { type: http, scheme: bearer, description: 実行ジョブの token } + delegation: type: http scheme: bearer - description: 実行ジョブの token + description: >- + 人が画面で発行した委任の資格情報(ndfd_<資格情報の ID>_<秘密>)。期限は 1 時間以内で、人だけが確定できる操作は呼べない responses: E400: - description: 要求の形が違う - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 要求全体の形が違う(invalid_request・empty_items・too_many_items・duplicate_key)。どの要素も処理しない + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E401: - description: 認証できない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: 資格情報が無い・無効・期限切れ・取り消し済み(unauthorized) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } E403: - description: 権限が無い(他人のアカウントと同じ本文) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E404: - description: 見つからない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - E409: - description: 状態が合わない - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: >- + 経路のアカウントを解決できない(forbidden)・委任で人だけの操作を呼んだ(human_only)・読み取りだけの範囲で書き込んだ(scope_insufficient) + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } + E404: { description: 見つからない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } + E409: { description: 状態が合わない, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } E503: - description: 時間をおいてやり直す - content: - application/json: - schema: - $ref: "#/components/schemas/Error" + description: 保存か操作の記録に失敗した。時間をおいてやり直す + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } schemas: Error: type: object @@ -690,20 +626,14 @@ components: type: object required: [code, message] properties: - code: { type: string } - message: { type: string } + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } details: { type: object } - Url: - type: object - required: [url] - properties: - url: { type: string, format: uri } + Url: { type: object, required: [url], properties: { url: { type: string, format: uri } } } CandidatesRequest: type: object required: [code, state] - properties: - code: { type: string, minLength: 1 } - state: { type: string, minLength: 1 } + properties: { code: { type: string, minLength: 1 }, state: { type: string, minLength: 1 } } RepositoryCandidates: type: object required: [proof, candidates] @@ -722,9 +652,7 @@ components: RegisterRequest: type: object required: [proof, repository_id] - properties: - proof: { type: string } - repository_id: { type: integer, minimum: 1 } + properties: { proof: { type: string }, repository_id: { type: integer, minimum: 1 } } Repository: type: object required: [repository_id, full_name, installation_id, registered_at] @@ -734,36 +662,21 @@ components: installation_id: { type: integer } registered_at: { type: string, format: date-time } active_sprint_runs: { type: integer, minimum: 0 } - max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 } - RepositoryUpdate: - type: object - required: [max_concurrent_runs] - additionalProperties: false - properties: - max_concurrent_runs: { type: ["integer", "null"], minimum: 1, maximum: 100 } - RepositoryList: - type: object - required: [repositories] - properties: - repositories: - type: array - items: { $ref: "#/components/schemas/Repository" } + max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 } ModelCredential: type: object required: [registered, kind, updated_at, external_id] properties: registered: { type: boolean } - kind: { type: ["string", "null"], enum: [anthropic_api_key, aws_role, null] } - updated_at: { type: ["string", "null"], format: date-time } + kind: { type: [string, "null"], enum: [anthropic_api_key, aws_role, null] } + updated_at: { type: [string, "null"], format: date-time } external_id: { type: string } ModelCredentialInput: oneOf: - type: object required: [kind, api_key] additionalProperties: false - properties: - kind: { const: anthropic_api_key } - api_key: { type: string, minLength: 1, maxLength: 512 } + properties: { kind: { const: anthropic_api_key }, api_key: { type: string, minLength: 1, maxLength: 512 } } - type: object required: [kind, role_arn, region] additionalProperties: false @@ -771,19 +684,13 @@ components: kind: { const: aws_role } role_arn: { type: string, maxLength: 2048, pattern: "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$" } region: { type: string, maxLength: 32, pattern: "^[a-z]{2}(-[a-z]+)+-\\d$" } - IssueCandidatesRequest: - type: object - properties: - exclude: - type: array - items: { type: integer, minimum: 1 } IssueCandidates: type: object required: [source, milestone, candidates, note] properties: source: { type: string, enum: [ranking, recent] } - milestone: { type: ["string", "null"] } - note: { type: ["string", "null"] } + milestone: { type: [string, "null"] } + note: { type: [string, "null"] } candidates: type: array items: @@ -794,70 +701,29 @@ components: title: { type: string } selected: { type: boolean } reason: { type: string, minLength: 1 } - StartRequest: - type: object - required: [repository_id, issues] - properties: - repository_id: { type: integer, minimum: 1 } - issues: - type: array - minItems: 1 - maxItems: 20 - uniqueItems: true - items: { type: integer, minimum: 1 } SprintRunStarted: type: object required: [sprint_run_id, state] - properties: - sprint_run_id: { type: string } - state: { const: starting } - SprintRunList: - type: object - required: [sprint_runs] - properties: - sprint_runs: - type: array - items: - type: object - required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at] - properties: - sprint_run_id: { type: string } - repository_id: { type: integer } - issues: { type: array, items: { type: integer } } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } - created_at: { type: string, format: date-time } - repo: { type: string, description: owner/name } - current_phase: - type: ["string", "null"] - description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す - current_step: { type: ["string", "null"], description: 最後の step.recorded のステップ } - last_event_at: { type: ["string", "null"], format: date-time, description: 最後の事象の時刻 } + properties: { sprint_run_id: { type: string }, state: { const: starting } } PullRequestLink: type: object required: [repo, number, url] - properties: - repo: { type: string } - number: { type: integer } - url: { type: string, format: uri } + properties: { repo: { type: string }, number: { type: integer }, url: { type: string, format: uri } } StepRow: type: object required: [key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details] properties: key: { type: string, description: 事象の idempotency_key } at: { type: string, format: date-time } - job_id: { type: ["string", "null"] } + job_id: { type: [string, "null"] } step: { type: string } - phase: { type: ["string", "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く } + phase: { type: [string, "null"], description: 工程のフェーズ。無ければ同じ実行ジョブの直前の phase.changed から導く } issues: { type: array, items: { type: integer }, description: 空はスプリント全体の工程 } exit_code: { type: integer } seconds: { type: number } - cost_usd: { type: ["number", "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null } + cost_usd: { type: [number, "null"], description: 同じ実行ジョブ・ステップ・時刻の usage.recorded の和。結べなければ null } summary: { type: string } - pull_request: - oneOf: - - $ref: "#/components/schemas/PullRequestLink" - - type: "null" + pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] } has_details: { type: boolean, description: 工程の中身の本文を持つか } RequiredStep: type: object @@ -869,17 +735,14 @@ components: oneOf: - type: object required: [key, exit_code, seconds] - properties: - key: { type: string } - exit_code: { type: integer } - seconds: { type: number } - - type: "null" + properties: { key: { type: string }, exit_code: { type: integer }, seconds: { type: number } } + - { type: "null" } GateView: type: object required: [state, at] properties: state: { type: string, enum: [not_reached, waiting, approved, rejected] } - at: { type: ["string", "null"], format: date-time } + at: { type: [string, "null"], format: date-time } SprintRunDetail: type: object required: @@ -905,17 +768,20 @@ components: sprint_run_id: { type: string } repository_id: { type: integer } issues: { type: array, items: { type: integer } } - state: { type: string, enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] } - failure_reason: { type: ["string", "null"] } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } created_at: { type: string, format: date-time } repo: { type: string } ndf_version: { type: string } execution_mode: { type: string, enum: [local, byok, managed] } job_count: { type: integer } - current_phase: { type: ["string", "null"] } + current_phase: { type: [string, "null"] } phases: type: array - description: 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ + description: >- + 5 つの名前(設計・実装・検査・取り込み・仕上げ)の順に、続いて現れた順に 5 つに無い名前。仕上げだけが required(振り返り・棚卸し)を持つ items: type: object required: [name, step_keys, required] @@ -930,7 +796,7 @@ components: type: object required: [issue, step_keys, pull_requests] properties: - issue: { type: ["integer", "null"] } + issue: { type: [integer, "null"] } step_keys: { type: array, items: { type: string } } pull_requests: type: array @@ -949,7 +815,7 @@ components: required: [name, conclusion] properties: name: { type: string } - conclusion: { type: ["string", "null"], description: null は実行中 } + conclusion: { type: [string, "null"], description: null は実行中 } steps: { type: array, items: { $ref: "#/components/schemas/StepRow" } } gates: type: object @@ -962,44 +828,37 @@ components: required: [state, at] properties: state: { type: string, enum: [none, waiting, answered] } - at: { type: ["string", "null"], format: date-time } + at: { type: [string, "null"], format: date-time } delegated_decisions: { type: integer, description: AI が代わりに判断した問い合わせの数 } usage: type: object required: [input_tokens, output_tokens, cost_usd] - properties: - input_tokens: { type: integer } - output_tokens: { type: integer } - cost_usd: { type: number } + properties: { input_tokens: { type: integer }, output_tokens: { type: integer }, cost_usd: { type: number } } omitted_steps: { type: integer, description: 上限で省いた古い工程の数 } StepDetail: allOf: - - $ref: "#/components/schemas/StepRow" + - { $ref: "#/components/schemas/StepRow" } - type: object required: [files, files_omitted, commit_range, decisions, output, diff_url] properties: files: - type: ["array", "null"] + type: [array, "null"] description: null は記録なし、空の配列は触らなかった items: type: object required: [path, added, deleted, url] properties: path: { type: string } - added: { type: ["integer", "null"], description: null はバイナリ } - deleted: { type: ["integer", "null"], description: null はバイナリ } - url: { type: ["string", "null"], format: uri, description: コミットの範囲が無ければ null } - files_omitted: { type: ["integer", "null"] } + added: { type: [integer, "null"], description: null はバイナリ } + deleted: { type: [integer, "null"], description: null はバイナリ } + url: { type: [string, "null"], format: uri, description: コミットの範囲が無ければ null } + files_omitted: { type: [integer, "null"] } commit_range: oneOf: - - type: object - required: [from, to] - properties: - from: { type: string } - to: { type: string } - - type: "null" + - { type: object, required: [from, to], properties: { from: { type: string }, to: { type: string } } } + - { type: "null" } decisions: - type: ["array", "null"] + type: [array, "null"] items: type: object required: [question, answer, by] @@ -1015,8 +874,8 @@ components: kind: { type: string, enum: [report, log] } text: { type: string } truncated: { type: boolean } - - type: "null" - diff_url: { type: ["string", "null"], format: uri } + - { type: "null" } + diff_url: { type: [string, "null"], format: uri } Backlog: type: object required: [repository_id, repo, ranking, refinements] @@ -1039,7 +898,7 @@ components: rank: { type: integer, minimum: 1 } number: { type: integer } title: { type: string } - - type: "null" + - { type: "null" } refinements: type: array description: 棚卸しの工程(step が refine)の記録を新しい順に @@ -1052,61 +911,47 @@ components: at: { type: string, format: date-time } exit_code: { type: integer } summary: { type: string } - pull_request: - oneOf: - - $ref: "#/components/schemas/PullRequestLink" - - type: "null" + pull_request: { oneOf: [{ $ref: "#/components/schemas/PullRequestLink" }, { type: "null" }] } CancelResult: type: object required: [state] - properties: - state: { type: string, enum: [cancelled, running] } + properties: { state: { type: string, enum: [cancelled, running] } } GitHubToken: type: object required: [token, expires_at] - properties: - token: { type: string } - expires_at: { type: string, format: date-time } - StateRequest: - type: object - required: [op] - properties: - op: { type: string, enum: [put, get] } + properties: { token: { type: string }, expires_at: { type: string, format: date-time } } + StateRequest: { type: object, required: [op], properties: { op: { type: string, enum: [put, get] } } } ApprovalAnswer: type: object required: [kind] description: >- - kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で - gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと - body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。 + kind が gate1 / gate2 なら decision が要り、ランナーはスプリント状態ファイルへ sprint-state.py gate で gate(無ければ kind を NDF の記録の名前へ写した「関門 1」/「関門 2」)の名前で書く。rejected なら差し戻しと body を conductor の再開の指示へ渡す。decision なら body を conductor への利用者の判断として渡す。 properties: kind: { type: string, enum: [gate1, gate2, decision] } decision: { type: string, enum: [approved, rejected] } - body: { type: ["string", "null"], description: 承認の理由か判断の本文 } + body: { type: [string, "null"], description: 承認の理由か判断の本文 } gate: { type: string, description: スプリントの状態に書く関門の名前(省略時は kind から「関門 1」/「関門 2」) } SignedUrl: type: object required: [url, expires_at] - properties: - url: { type: string, format: uri } - expires_at: { type: string, format: date-time } + properties: { url: { type: string, format: uri }, expires_at: { type: string, format: date-time } } OrganizerAnswer: type: object required: [request_id, result, version, release, reason, at] properties: request_id: { type: string } result: { type: string, enum: [released, rejected] } - version: { type: ["string", "null"] } - release: { type: ["string", "null"], description: 本番の Pull Request の URL } - reason: { type: ["string", "null"] } + version: { type: [string, "null"] } + release: { type: [string, "null"], description: 本番の Pull Request の URL } + reason: { type: [string, "null"] } at: { type: string, format: date-time } OrganizerRange: type: object required: [seq, sha, version, prs, lanes] properties: seq: { type: integer } - sha: { type: ["string", "null"] } - version: { type: ["string", "null"] } + sha: { type: [string, "null"] } + version: { type: [string, "null"] } prs: { type: array, items: { type: integer } } lanes: { type: array, items: { type: string } } OrganizerAnswers: @@ -1125,9 +970,7 @@ components: items: type: object required: [id, prs] - properties: - id: { type: string } - prs: { type: array, items: { type: integer } } + properties: { id: { type: string }, prs: { type: array, items: { type: integer } } } issues: { type: array, items: { type: integer } } OrganizerBatch: type: object @@ -1138,3 +981,62 @@ components: lanes: { type: array, items: { $ref: "#/components/schemas/OrganizerBatchLane" } } presentations: { type: array, items: { type: object } } choices: { type: array, items: { type: string, enum: [continue, reject] } } + ElementFailure: + type: object + description: 失敗した要素。認可の無い要素は forbidden だけを持ち、その要素のデータを含めない + required: [ok, status, error] + properties: + ok: { const: false } + status: { type: integer, enum: [400, 403, 404, 409, 422, 503] } + error: + type: object + required: [code, message] + properties: + code: { type: string, description: 機械の値 } + message: { type: string, description: 利用者に出す文 } + details: { type: object } + IssueCandidatesItem: + type: object + properties: + repository_id: { type: integer, minimum: 1 } + exclude: { type: array, items: { type: integer, minimum: 1 } } + required: [repository_id] + StartSprintRunItem: + type: object + required: [idempotency_key, repository_id, issues] + properties: + idempotency_key: + type: string + pattern: "^[0-9A-Za-z-]{1,64}$" + description: 作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする + repository_id: { type: integer, minimum: 1 } + issues: { type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: { type: integer, minimum: 1 } } + RepositoryUpdateItem: + type: object + required: [repository_id, max_concurrent_runs] + additionalProperties: false + properties: + repository_id: { type: integer, minimum: 1 } + max_concurrent_runs: { type: [integer, "null"], minimum: 1, maximum: 100 } + SprintRunRef: + type: object + required: [sprint_run_id] + additionalProperties: false + properties: { sprint_run_id: { type: string, minLength: 1 } } + Removed: { type: object, required: [removed], properties: { removed: { const: true } } } + SprintRunRow: + type: object + required: [sprint_run_id, repository_id, issues, state, failure_reason, created_at] + properties: + sprint_run_id: { type: string } + repository_id: { type: integer } + issues: { type: array, items: { type: integer } } + state: + type: string + enum: [starting, running, awaiting_approval, awaiting_reset, done, stopped, failed, cancelled] + failure_reason: { type: [string, "null"] } + created_at: { type: string, format: date-time } + repo: { type: string, description: owner/name } + current_phase: { type: [string, "null"], description: 最後の phase.changed のフェーズの名前。null は始まる前。5 つの名前に無い値もそのまま返す } + current_step: { type: [string, "null"], description: 最後の step.recorded のステップ } + last_event_at: { type: [string, "null"], format: date-time, description: 最後の事象の時刻 } diff --git a/factory/contracts/package.json b/factory/contracts/package.json index 99b1d6f47..83b3e6283 100644 --- a/factory/contracts/package.json +++ b/factory/contracts/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./phases": "./src/phases.ts", - "./views": "./src/views.ts" + "./views": "./src/views.ts", + "./operations": "./src/operations.ts" }, "scripts": { "generate": "node scripts/generate.ts", @@ -15,7 +16,9 @@ "check": "npm run typecheck && npm run test" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^24.19.2", + "js-yaml": "^4.3.2", "json-schema-to-typescript": "^16.0.0", "typescript": "^7.0.2", "vitest": "^5.0.5" diff --git a/factory/contracts/scripts/generate.ts b/factory/contracts/scripts/generate.ts index 075581fc7..d617f0b45 100644 --- a/factory/contracts/scripts/generate.ts +++ b/factory/contracts/scripts/generate.ts @@ -1,9 +1,11 @@ -// JSON Schema(schemas/)から TypeScript の型(src/generated/)を作る。定義が正で、型は生成物(決定 2) -// node scripts/generate.ts 生成した型を書く -// node scripts/generate.ts --check 書かずに、コミットした型と食い違えば 1 で終わる +// JSON Schema(schemas/)から TypeScript の型(src/generated/)を、契約(openapi/)から操作の表を作る。 +// 定義が正で、型と表は生成物(決定 2・#2384 の決定 5) +// node scripts/generate.ts 生成した型と表を書く +// node scripts/generate.ts --check 書かずに、コミットした型か表と食い違えば 1 で終わる import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { compile, type JSONSchema } from "json-schema-to-typescript"; +import { generateOperations, operationsPath } from "./operations.ts"; const root = fileURLToPath(new URL("..", import.meta.url)); @@ -37,6 +39,8 @@ export async function staleSchemas(): Promise { const committed = await readFile(generatedPath(name), "utf8").catch(() => null); if (committed !== (await generateOne(name))) stale.push(name); } + const committed = await readFile(operationsPath, "utf8").catch(() => null); + if (committed !== (await generateOperations())) stale.push("operations"); return stale; } @@ -50,6 +54,7 @@ async function main(): Promise { return; } for (const name of SCHEMAS) await writeFile(generatedPath(name), await generateOne(name)); + await writeFile(operationsPath, await generateOperations()); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main(); diff --git a/factory/contracts/scripts/operations.ts b/factory/contracts/scripts/operations.ts new file mode 100644 index 000000000..3ab9345bf --- /dev/null +++ b/factory/contracts/scripts/operations.ts @@ -0,0 +1,123 @@ +// 契約(openapi/*.yaml)から操作の表(src/generated/operations.ts)を作る(#2384 の決定 5)。 +// 利用者向けの操作(x-ndf-access を持つ操作)だけを取り、画面・API・MCP・検査が同じ表を読む +import { readdir, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +export const openapiDir = `${root}openapi`; +export const operationsPath = `${root}src/generated/operations.ts`; + +const METHODS = ["get", "post", "put", "patch", "delete"] as const; +const ACCESS = new Set(["read", "write", "human"]); + +type Json = null | boolean | number | string | Json[] | { [k: string]: Json }; +type Obj = { [k: string]: Json }; + +export interface GeneratedParameter { + name: string; + required: boolean; + array: boolean; + description: string | null; + schema: Json; +} + +export interface GeneratedOperation { + operationId: string; + method: string; + path: string; + kind: string; + access: string; + keys: string[]; + idField: string | null; + summary: string; + description: string; + parameters: GeneratedParameter[]; + itemSchema: Json | null; + source: string; +} + +const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); + +// 同じファイルの中の $ref(#/components/...)をほどく。ファイルの外の参照は利用者向けの操作に無い +function deref(doc: Obj, value: Json, seen: string[] = []): Json { + if (Array.isArray(value)) return value.map((v) => deref(doc, v, seen)); + if (!isObj(value)) return value; + const ref = value.$ref; + if (typeof ref === "string") { + if (!ref.startsWith("#/")) throw new Error(`ファイルの外の参照は操作の表に入れられません: ${ref}`); + if (seen.includes(ref)) throw new Error(`参照が巡っています: ${ref}`); + let node: Json = doc; + for (const part of ref.slice(2).split("/")) node = isObj(node) ? (node[part] ?? null) : null; + if (node === null) throw new Error(`参照が見つかりません: ${ref}`); + return deref(doc, node, [...seen, ref]); + } + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deref(doc, v, seen)])); +} + +// 種類は経路の形とメソッドから決める(契約の表の「操作の種類と形」)。POST は、作った識別子を返すか、 +// 同じ経路に別のメソッドがあれば作成、それ以外(経路の末尾が動詞)は操作 +function kindOf(method: string, path: string, op: Obj, pathItem: Obj): string { + if (method === "get") return Array.isArray(op["x-ndf-keys"]) ? "select" : "list"; + if (method === "delete") return "delete"; + if (method === "put" || method === "patch") return "update"; + const others = METHODS.filter((m) => m !== "post" && isObj(pathItem[m])); + return typeof op["x-ndf-id"] === "string" || others.length > 0 ? "create" : "action"; +} + +export async function readOperations(dir = openapiDir): Promise { + const files = (await readdir(dir)).filter((f) => f.endsWith(".yaml") && !f.startsWith(".")).sort(); + const out: GeneratedOperation[] = []; + for (const file of files) { + const doc = yaml.load(await readFile(`${dir}/${file}`, "utf8")) as Obj; + const paths = isObj(doc.paths) ? doc.paths : {}; + for (const [path, item] of Object.entries(paths)) { + if (!isObj(item)) continue; + for (const method of METHODS) { + const op = item[method]; + if (!isObj(op) || op["x-ndf-access"] === undefined) continue; + const access = String(op["x-ndf-access"]); + if (!ACCESS.has(access)) throw new Error(`${op.operationId}: x-ndf-access は read・write・human のどれかです`); + const params = (Array.isArray(op.parameters) ? op.parameters : []).map((p) => deref(doc, p)).filter(isObj); + const body = isObj(op.requestBody) ? deref(doc, op.requestBody) : null; + const bodySchema = isObj(body) && isObj(body.content) && isObj(body.content["application/json"]) ? body.content["application/json"].schema : null; + out.push({ + operationId: String(op.operationId), + method: method.toUpperCase(), + path, + kind: kindOf(method, path, op, item), + access, + keys: Array.isArray(op["x-ndf-keys"]) ? op["x-ndf-keys"].map(String) : [], + idField: typeof op["x-ndf-id"] === "string" ? op["x-ndf-id"] : null, + summary: String(op.summary ?? ""), + description: String(op.description ?? ""), + parameters: params + .filter((p) => p.in === "query") + .map((p) => ({ + name: String(p.name), + required: p.required === true, + array: isObj(p.schema) && p.schema.type === "array", + description: typeof p.description === "string" ? p.description : null, + schema: p.schema ?? null, + })), + itemSchema: isObj(bodySchema) ? (bodySchema.items ?? null) : null, + source: file, + }); + } + } + } + return out.sort((a, b) => a.operationId.localeCompare(b.operationId)); +} + +const BANNER = "// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す"; + +export async function generateOperations(dir = openapiDir): Promise { + const ops = await readOperations(dir); + return [ + BANNER, + 'import type { Operation } from "../operations.ts";', + "", + `export const OPERATIONS = ${JSON.stringify(ops, null, 2)} as const satisfies readonly Operation[];`, + "", + ].join("\n"); +} diff --git a/factory/contracts/src/generated/operations.ts b/factory/contracts/src/generated/operations.ts new file mode 100644 index 000000000..06b5b4cbe --- /dev/null +++ b/factory/contracts/src/generated/operations.ts @@ -0,0 +1,1227 @@ +// 生成したファイル。手で直さない。契約(openapi/)を直して `npm run generate -w contracts` で作り直す +import type { Operation } from "../operations.ts"; + +export const OPERATIONS = [ + { + "operationId": "addSeat", + "method": "POST", + "path": "/accounts/{accountId}/seats", + "kind": "create", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席を足す", + "description": "チームの持ち主が、既に 1 度ログインした利用者を ID で指して席を足す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "user_id", + "seat_type" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "answerLoopPending", + "method": "POST", + "path": "/accounts/{accountId}/loops/answers", + "kind": "action", + "access": "human", + "keys": [ + "loop_id", + "issue" + ], + "idField": null, + "summary": "人の判断待ちの課題に答える", + "description": "答えは次のジョブの LOOP_ANSWERS に載り、ランナーが NDF の loop answer で書く。人の判断待ちに無い番号は 409 not_pending。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id", + "issue", + "decision" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + }, + "issue": { + "type": "integer", + "minimum": 1 + }, + "decision": { + "type": "string", + "enum": [ + "approved", + "rejected" + ] + }, + "pr": { + "type": "integer", + "minimum": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "cancelLoop", + "method": "POST", + "path": "/accounts/{accountId}/loops/cancel", + "kind": "action", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループを止める", + "description": "動いている実行があればスプリントの実行の取り消しと同じ形で止め、停止の後に cancelled へ移す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "cancelSprintRun", + "method": "POST", + "path": "/accounts/{accountId}/sprint-runs/cancel", + "kind": "action", + "access": "write", + "keys": [ + "sprint_run_id" + ], + "idField": null, + "summary": "スプリントの実行を取り消す", + "description": "動いている実行ジョブがあれば止め、停止の後に cancelled へ移す。main へマージした後の配布の実行は取り消さず、止まった配布(release_stalled)として残す(ジョブが無ければ 409 release_after_main)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "sprint_run_id" + ], + "additionalProperties": false, + "properties": { + "sprint_run_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "changeSeat", + "method": "PATCH", + "path": "/accounts/{accountId}/seats", + "kind": "update", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席の種類を変える", + "description": "チームの持ち主だけが変えられる。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "user_id", + "seat_type" + ], + "properties": { + "user_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "createDelegation", + "method": "POST", + "path": "/delegations", + "kind": "create", + "access": "human", + "keys": [ + "idempotency_key" + ], + "idField": "delegation.credential_id", + "summary": "委任の資格情報を発行する", + "description": "値(ndfd_<資格情報の ID>_<秘密>)は発行の結果で 1 度だけ返し、平文を保存しない。同じ冪等の鍵の 2 回目は同じ資格情報を返し、credential を null にする。期限は 300〜3,600 秒。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "client_name", + "scope", + "expires_in_seconds", + "idempotency_key" + ], + "additionalProperties": false, + "properties": { + "client_name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "人が付けるクライアントの名前。操作の記録には写さない" + }, + "scope": { + "type": "string", + "description": "read(読み取りだけ)・read_write(読み書き)。どちらも人だけが確定できる操作は呼べない", + "enum": [ + "read", + "read_write" + ] + }, + "expires_in_seconds": { + "type": "integer", + "minimum": 300, + "maximum": 3600 + }, + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵" + } + } + }, + "source": "delegations.yaml" + }, + { + "operationId": "createTeam", + "method": "POST", + "path": "/teams", + "kind": "create", + "access": "human", + "keys": [ + "idempotency_key" + ], + "idField": "account_id", + "summary": "チームのアカウントを作る", + "description": "作った利用者がチームの持ち主になり、持ち主の席が 1 つできる。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "idempotency_key", + "name", + "seat_type" + ], + "properties": { + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "seat_type": { + "type": "string", + "enum": [ + "standard", + "premium" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "deleteModelCredential", + "method": "DELETE", + "path": "/accounts/{accountId}/model-credential", + "kind": "delete", + "access": "human", + "keys": [], + "idField": null, + "summary": "持ち込んだキーを消す", + "description": "復元の期間を置かずに消す(決定 14)。無くても成功の結果を返す。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getAccount", + "method": "GET", + "path": "/accounts", + "kind": "select", + "access": "read", + "keys": [ + "account_id" + ], + "idField": null, + "summary": "アカウントの料金プラン・実行の形態・使える量・席", + "description": "個人のアカウントの持ち主か、チームの席を持つ利用者だけが読める。", + "parameters": [ + { + "name": "account_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "getAuthorizeUrl", + "method": "GET", + "path": "/accounts/{accountId}/github/authorize-url", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "GitHub App の利用者の認可の URL", + "description": "state は利用者とアカウントを署名した 10 分の値。持ち主だけが使える。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getBacklog", + "method": "GET", + "path": "/accounts/{accountId}/backlogs", + "kind": "select", + "access": "read", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "バックログ", + "description": "リポジトリの着手の順位の表(マイルストーンの説明の表)と、そのリポジトリの実行(新しい順に 50 件まで)の棚卸しの工程の記録。 読むだけで、課題・マイルストーン・ボードへ書かない。登録の無いリポジトリの要素も、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getInstallUrl", + "method": "GET", + "path": "/accounts/{accountId}/github/install-url", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "GitHub App の導入の画面の URL", + "description": "GitHub App の導入の URL。要素 1 個の配列で返す。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getLoop", + "method": "GET", + "path": "/accounts/{accountId}/loops/detail", + "kind": "select", + "access": "read", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループの詳細", + "description": "締めた周回・周回ごとの実行と区間の数・人の判断待ちと答え。", + "parameters": [ + { + "name": "loop_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "loops.yaml" + }, + { + "operationId": "getMe", + "method": "GET", + "path": "/me", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "自分の利用者と、席を持つアカウントの一覧", + "description": "初めての要求では、Free・local の個人のアカウントを 1 つ作る。", + "parameters": [], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "getModelCredential", + "method": "GET", + "path": "/accounts/{accountId}/model-credential", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "持ち込んだキーの有無", + "description": "登録済みか・種類・更新の時刻・外部 ID だけを返し、値も置き場の ARN も返さない(I38)。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getSprintRunDetail", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs/detail", + "kind": "select", + "access": "read", + "keys": [ + "sprint_run_id" + ], + "idField": null, + "summary": "スプリントの詳細", + "description": "フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量。進捗の事象から導き、課題の本文・ボード・GitHub を読まない。 工程の中身の本文(触ったファイル・決めたこと・出力)は返さず、工程の中身の操作(getStepDetail)で読む。 step.recorded は新しい方から 2,000 件までで、省いた数を omitted_steps に返す。 無い実行・アカウントに属さない実行の要素は、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "sprint_run_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "getStepDetail", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs/steps", + "kind": "select", + "access": "read", + "keys": [ + "step_key" + ], + "idField": null, + "summary": "工程の中身", + "description": "1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)と、差分とファイルのリンク。 step_key は事象の idempotency_key。記録の無い項目は null。 無い工程・別の実行の工程の要素も、他人のアカウントと同じ本文の forbidden の結果になる。", + "parameters": [ + { + "name": "sprint_run_id", + "required": true, + "array": false, + "description": "工程を持つ実行。他人の実行なら要求全体が 403", + "schema": { + "type": "string" + } + }, + { + "name": "step_key", + "required": true, + "array": true, + "description": "工程の鍵(事象の idempotency_key)。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "listDelegations", + "method": "GET", + "path": "/delegations", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "自分が発行した委任の資格情報の一覧", + "description": "新しい順に 100 件まで。値と秘密のハッシュは返さない。", + "parameters": [], + "itemSchema": null, + "source": "delegations.yaml" + }, + { + "operationId": "listLoops", + "method": "GET", + "path": "/accounts/{accountId}/loops", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "リポジトリの開発ループの一覧", + "description": "新しい順に 50 件。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "itemSchema": null, + "source": "loops.yaml" + }, + { + "operationId": "listRepositories", + "method": "GET", + "path": "/accounts/{accountId}/repositories", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "登録したリポジトリの一覧", + "description": "リポジトリごとの終わりの状態でないスプリントの実行の数を添える。", + "parameters": [], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "listRepositoryApprovalRequests", + "method": "GET", + "path": "/accounts/{accountId}/approval-requests", + "kind": "select", + "access": "read", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの承認と問いの一覧", + "description": "同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・ゲート 2・判断)、止まった配布、開発ループの人の判断待ちを、 作った時刻の古い順に 1 つの並びで返す。承認の依頼の中身(承認資料・答え)は承認の依頼の経路が持ち、ここは識別子と要約だけを返す。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "organizer.yaml" + }, + { + "operationId": "listRepositoryReleases", + "method": "GET", + "path": "/accounts/{accountId}/releases", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "まとめた配布の履歴", + "description": "新しい順に limit 件(既定 20、最大 100)。提示・確定・結果と、待ち・組む・出し直し・確定・結果の記録を返す。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "limit", + "required": false, + "array": false, + "description": null, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "itemSchema": null, + "source": "organizer.yaml" + }, + { + "operationId": "listSprintRuns", + "method": "GET", + "path": "/accounts/{accountId}/sprint-runs", + "kind": "list", + "access": "read", + "keys": [], + "idField": null, + "summary": "スプリントの実行の一覧", + "description": "新しい順に 50 件。repository_id で絞れる。各行の今のフェーズ・今の工程・最後の動きは進捗の事象から導く。", + "parameters": [ + { + "name": "repository_id", + "required": false, + "array": false, + "description": "省くとアカウントの新しい順の 50 件", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "postIssueCandidates", + "method": "POST", + "path": "/accounts/{accountId}/issue-candidates", + "kind": "action", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "AI が選んだ課題の候補", + "description": "棚卸しの順位の表(無ければ開いている課題の新しい順)から、サービスの Bedrock で採否と理由を作る。保存しない。言語モデルを呼び費用が掛かるため、扱いは書き込み。", + "parameters": [], + "itemSchema": { + "type": "object", + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "exclude": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + } + } + }, + "required": [ + "repository_id" + ] + }, + "source": "runs.yaml" + }, + { + "operationId": "postRepositoryCandidates", + "method": "POST", + "path": "/accounts/{accountId}/github/candidates", + "kind": "action", + "access": "write", + "keys": [], + "idField": null, + "summary": "認可の戻りから登録の候補を返す", + "description": "導入の対象で利用者の届くリポジトリと、10 分の候補の証明を返す。利用者の token は保存しない。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "state": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "putModelCredential", + "method": "PUT", + "path": "/accounts/{accountId}/model-credential", + "kind": "update", + "access": "human", + "keys": [], + "idField": null, + "summary": "キーを保存・差し替える", + "description": "Anthropic API キーか AWS のロールの ARN と地域のどちらか 1 つ。持ち主だけが使える。", + "parameters": [], + "itemSchema": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind", + "api_key" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "anthropic_api_key" + }, + "api_key": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + }, + { + "type": "object", + "required": [ + "kind", + "role_arn", + "region" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "aws_role" + }, + "role_arn": { + "type": "string", + "maxLength": 2048, + "pattern": "^arn:aws[a-z-]*:iam::\\d{12}:role/.+$" + }, + "region": { + "type": "string", + "maxLength": 32, + "pattern": "^[a-z]{2}(-[a-z]+)+-\\d$" + } + } + } + ] + }, + "source": "runs.yaml" + }, + { + "operationId": "registerRepository", + "method": "POST", + "path": "/accounts/{accountId}/repositories", + "kind": "create", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリを登録する", + "description": "候補の証明と、登録の直前の導入の token の作成で確かめる(I33)。同じリポジトリは 409(I32)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "proof", + "repository_id" + ], + "properties": { + "proof": { + "type": "string" + }, + "repository_id": { + "type": "integer", + "minimum": 1 + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "removeRepository", + "method": "DELETE", + "path": "/accounts/{accountId}/repositories", + "kind": "delete", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの登録を外す", + "description": "終わりの状態でないスプリントの実行があれば 409(I36)。持ち主だけが使える。", + "parameters": [ + { + "name": "repository_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + ], + "itemSchema": null, + "source": "runs.yaml" + }, + { + "operationId": "removeSeat", + "method": "DELETE", + "path": "/accounts/{accountId}/seats", + "kind": "delete", + "access": "human", + "keys": [ + "user_id" + ], + "idField": null, + "summary": "席を外す", + "description": "チームの持ち主だけが外せる。持ち主自身の席は外せない。", + "parameters": [ + { + "name": "user_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "accounts.yaml" + }, + { + "operationId": "resolveStalledRelease", + "method": "POST", + "path": "/accounts/{accountId}/releases/resolve", + "kind": "action", + "access": "human", + "keys": [ + "repository_id", + "seq" + ], + "idField": null, + "summary": "止まった配布を解く", + "description": "配布の実行が失敗して止まったまとめた配布を「続きから流す」(continue)か「差し戻す」(reject)で解く。 止まっていなければ 409 not_stalled、main へのマージの後の差し戻しは 409 release_after_main(I63)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "seq", + "choice" + ], + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "seq": { + "type": "integer", + "minimum": 1 + }, + "choice": { + "type": "string", + "enum": [ + "continue", + "reject" + ] + }, + "body": { + "type": "string", + "maxLength": 4000 + } + } + }, + "source": "organizer.yaml" + }, + { + "operationId": "revokeDelegation", + "method": "DELETE", + "path": "/delegations", + "kind": "delete", + "access": "human", + "keys": [ + "credential_id" + ], + "idField": null, + "summary": "委任の資格情報を取り消す", + "description": "取り消しはその後の最初の要求から効く。期限切れと取り消し済みの資格情報は何も変えずに成功を返す。他人の資格情報は forbidden の結果になる。", + "parameters": [ + { + "name": "credential_id", + "required": true, + "array": true, + "description": "要素の鍵。繰り返して 1〜100 個を指す", + "schema": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + } + } + ], + "itemSchema": null, + "source": "delegations.yaml" + }, + { + "operationId": "setExecutionMode", + "method": "PATCH", + "path": "/accounts", + "kind": "update", + "access": "write", + "keys": [ + "account_id" + ], + "idField": null, + "summary": "実行の形態を変える", + "description": "アカウントの持ち主だけが変えられる。今と同じ値なら履歴を足さずに 200 を返す。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "account_id", + "execution_mode" + ], + "properties": { + "account_id": { + "type": "string", + "minLength": 1 + }, + "execution_mode": { + "type": "string", + "description": "local(手元のマシン)・byok(キーの持ち込み)・managed(サービスの Bedrock)", + "enum": [ + "local", + "byok", + "managed" + ] + } + } + }, + "source": "accounts.yaml" + }, + { + "operationId": "startLoop", + "method": "POST", + "path": "/accounts/{accountId}/loops", + "kind": "create", + "access": "write", + "keys": [ + "idempotency_key" + ], + "idField": "loop_id", + "summary": "開発ループを始める", + "description": "マイルストーンの持ち分とループの行を 1 回のトランザクションで書き、続けて最初の周回を起こす。 枠で待つときも成功の結果(between_laps)。マイルストーンが同じリポジトリの終わりの状態でないループと重なると 409 milestone_in_use(details.milestones・details.loop_name)、名前が重なると 409 loop_name_in_use。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "idempotency_key", + "name", + "milestones" + ], + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "name": { + "type": "string", + "pattern": "^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$" + }, + "milestones": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[^,]+$" + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "laps": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "usd": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "hours": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + } + } + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "startNextLap", + "method": "POST", + "path": "/accounts/{accountId}/loops/next-lap", + "kind": "action", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "次の周回を始める(形態 local)", + "description": "次の周回を始める操作を待っている(wait_reason が user_start の)ループだけ。ほかは 409 not_awaiting_user_start。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "startSprintRun", + "method": "POST", + "path": "/accounts/{accountId}/sprint-runs", + "kind": "create", + "access": "write", + "keys": [ + "idempotency_key" + ], + "idField": "sprint_run_id", + "summary": "スプリントの実行を始める", + "description": "確かめの順は 課題(400)→ 登録(404)→ キー(409)→ 導入(409)→ 使える量(409)。 課題が動いているループのマイルストーンに属すると 409 issue_in_loop(details.numbers・details.loop_name)、 同じリポジトリの終わりの状態でない実行が持つ課題を指すと 409 issue_in_use(details.numbers)、 並行を許さない版どうしなら 409 parallel_unsupported_version。limits_exceeded の reasons に concurrent_repo_runs がある(#2347)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "idempotency_key", + "repository_id", + "issues" + ], + "properties": { + "idempotency_key": { + "type": "string", + "pattern": "^[0-9A-Za-z-]{1,64}$", + "description": "作成の冪等の鍵。同じ利用者・同じ操作・同じ鍵の作成は 24 時間の間 1 件にする" + }, + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "issues": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "source": "runs.yaml" + }, + { + "operationId": "updateLoopBudget", + "method": "PATCH", + "path": "/accounts/{accountId}/loops", + "kind": "update", + "access": "write", + "keys": [ + "loop_id" + ], + "idField": null, + "summary": "開発ループの予算を変える", + "description": "終わりの状態でないループにだけ効き、次のジョブの指示から NDF の予算へ渡る。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "loop_id", + "budget" + ], + "additionalProperties": false, + "properties": { + "loop_id": { + "type": "string", + "minLength": 1 + }, + "budget": { + "type": "object", + "additionalProperties": false, + "properties": { + "laps": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "usd": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + }, + "hours": { + "type": [ + "number", + "null" + ], + "exclusiveMinimum": 0 + } + } + } + } + }, + "source": "loops.yaml" + }, + { + "operationId": "updateRepository", + "method": "PATCH", + "path": "/accounts/{accountId}/repositories", + "kind": "update", + "access": "write", + "keys": [ + "repository_id" + ], + "idField": null, + "summary": "リポジトリの同時の実行の数を宣言する", + "description": "料金プランの同時の数より小さい値だけが効く。null は宣言を外す。持ち主だけが使える(#2347)。", + "parameters": [], + "itemSchema": { + "type": "object", + "required": [ + "repository_id", + "max_concurrent_runs" + ], + "additionalProperties": false, + "properties": { + "repository_id": { + "type": "integer", + "minimum": 1 + }, + "max_concurrent_runs": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + } + }, + "source": "runs.yaml" + } +] as const satisfies readonly Operation[]; diff --git a/factory/contracts/src/operations.ts b/factory/contracts/src/operations.ts new file mode 100644 index 000000000..8cc39d71b --- /dev/null +++ b/factory/contracts/src/operations.ts @@ -0,0 +1,83 @@ +// 操作の表(#2384 の決定 5)。契約から生成した利用者向けの操作の一覧を、画面・API・MCP・検査が同じく読む +import { OPERATIONS } from "./generated/operations.ts"; + +export { OPERATIONS }; + +// 操作の扱い。read(読み取り)・write(書き込み)・human(人だけが確定できる。委任の資格情報からは断る) +export type OperationAccess = "read" | "write" | "human"; +// 操作の種類。list は条件で集めた値の配列を返し、ほかは要素の結果を入力と同じ順に返す +export type OperationKind = "list" | "select" | "create" | "update" | "delete" | "action"; +export type OperationMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface OperationParameter { + readonly name: string; + readonly required: boolean; + // 鍵の query は繰り返す(?a=1&a=2) + readonly array: boolean; + readonly description: string | null; + readonly schema: unknown; +} + +export interface Operation { + readonly operationId: string; + readonly method: OperationMethod; + // 経路の変数は {accountId} だけ + readonly path: string; + readonly kind: OperationKind; + readonly access: OperationAccess; + // 要素の鍵の項目の名前。鍵を持たない操作は空で、要素は必ず 1 個 + readonly keys: readonly string[]; + // 作成の成功の値の中の、作った識別子の項目(`.` で入れ子を指す) + readonly idField: string | null; + readonly summary: string; + readonly description: string; + readonly parameters: readonly OperationParameter[]; + // 作成・更新・操作の本文の要素の定義(参照をほどいた JSON Schema) + readonly itemSchema: unknown; + readonly source: string; +} + +export type OperationId = (typeof OPERATIONS)[number]["operationId"]; + +// 1 回の要求で受け渡す要素の上限(要求の前提 3) +export const MAX_ITEMS = 100; + +// 操作の記録の target_ids へ写してよい鍵の名前(契約の表の「操作の記録へ写す鍵」。決定 12) +export const AUDIT_KEYS = [ + "account_id", + "user_id", + "repository_id", + "sprint_run_id", + "step_key", + "loop_id", + "issue", + "seq", + "credential_id", + "idempotency_key", +] as const; + +const byId = new Map(OPERATIONS.map((op) => [op.operationId, op as Operation])); + +export function operation(operationId: OperationId): Operation { + return byId.get(operationId)!; +} + +export function findOperation(operationId: string): Operation | null { + return byId.get(operationId) ?? null; +} + +// 経路の {accountId} を埋め、query を足した URL の経路を返す。配列の値は同じ名前で繰り返す +export function operationUrl(op: Operation, accountId: string | null, query: Record = {}): string { + let path = op.path; + if (path.includes("{accountId}")) { + if (accountId === null) throw new Error(`${op.operationId} はアカウントの ID が要ります`); + path = path.replace("{accountId}", encodeURIComponent(accountId)); + } + const params = new URLSearchParams(); + for (const [name, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + for (const v of Array.isArray(value) ? value : [value]) params.append(name, String(v)); + } + const qs = params.toString(); + return qs ? `${path}?${qs}` : path; +} diff --git a/factory/contracts/tests/operations.test.ts b/factory/contracts/tests/operations.test.ts new file mode 100644 index 000000000..632a63495 --- /dev/null +++ b/factory/contracts/tests/operations.test.ts @@ -0,0 +1,118 @@ +// 機能から起こした契約の形と操作の表(#2384 の受け入れ条件 4・5・16、不変条件 I10) +import { readdir, readFile } from "node:fs/promises"; +import yaml from "js-yaml"; +import { describe, expect, it } from "vitest"; +import { openapiDir, readOperations } from "../scripts/operations.ts"; +import { AUDIT_KEYS, OPERATIONS, operation, operationUrl } from "../src/operations.ts"; + +type Obj = Record; +const METHODS = ["get", "post", "put", "patch", "delete"]; + +async function allOperations(): Promise<{ file: string; path: string; method: string; op: Obj; doc: Obj }[]> { + const out = []; + for (const file of (await readdir(openapiDir)).filter((f) => f.endsWith(".yaml") && !f.startsWith("."))) { + const doc = yaml.load(await readFile(`${openapiDir}/${file}`, "utf8")) as Obj; + for (const [path, item] of Object.entries(doc.paths as Obj)) { + for (const method of METHODS) if (item[method]) out.push({ file, path, method, op: item[method] as Obj, doc }); + } + } + return out; +} + +function resolve(doc: Obj, schema: Obj): Obj { + if (typeof schema.$ref !== "string") return schema; + return schema.$ref + .slice(2) + .split("/") + .reduce((node: Obj, part: string) => node[part], doc); +} + +describe("利用者向けの操作の形(受け入れ条件 4・5)", () => { + it("成功の応答と要求の本文が配列である", async () => { + const userOps = (await allOperations()).filter((o) => o.op["x-ndf-access"] !== undefined); + expect(userOps.length).toBe(OPERATIONS.length); + for (const { op, doc } of userOps) { + const ok = op.responses["200"]; + expect(ok, `${op.operationId} の 200`).toBeDefined(); + expect(resolve(doc, ok.content["application/json"].schema).type, op.operationId).toBe("array"); + for (const status of Object.keys(op.responses)) expect(["200", "400", "401", "403", "503"], `${op.operationId} の ${status}`).toContain(status); + if (op.requestBody) { + const schema = resolve(doc, op.requestBody.content["application/json"].schema); + expect(schema.type, op.operationId).toBe("array"); + expect([schema.minItems, schema.maxItems], op.operationId).toEqual([1, 100]); + } + } + }); + + it("経路の変数はアカウントの ID だけで、単一のレコードを経路で指す取得が無い", async () => { + for (const { path, op } of await allOperations()) { + if (op["x-ndf-access"] === undefined) continue; + const vars = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]); + expect(vars.filter((v) => v !== "accountId"), `${op.operationId} の ${path}`).toEqual([]); + } + }); + + it("要素を持つ操作の鍵が、query か本文の要素の項目にある", async () => { + for (const op of OPERATIONS) { + if (op.kind === "list") continue; + for (const key of op.keys) { + const inQuery = op.parameters.some((p) => p.name === key && p.array); + const item = op.itemSchema as { properties?: Record } | null; + expect(inQuery || Boolean(item?.properties?.[key]), `${op.operationId} の ${key}`).toBe(true); + } + } + }); + + it("ランナー向けの経路と受け口は操作の表に入らない", () => { + for (const op of OPERATIONS) expect(op.path.startsWith("/run-jobs/") || op.path.startsWith("/ingest/"), op.operationId).toBe(false); + }); +}); + +describe("操作の扱い(受け入れ条件 16、決定 10)", () => { + it("人だけが確定できる操作は 10 で、ループの判断待ちへの答えを含む", () => { + const human = OPERATIONS.filter((op) => op.access === "human").map((op) => op.operationId); + expect(human.sort()).toEqual( + [ + "addSeat", + "answerLoopPending", + "changeSeat", + "createDelegation", + "createTeam", + "deleteModelCredential", + "putModelCredential", + "removeSeat", + "resolveStalledRelease", + "revokeDelegation", + ].sort(), + ); + expect(operation("answerLoopPending").access).toBe("human"); + }); + + it("読み取りの操作は GET だけで、GET の操作はすべて読み取り", () => { + for (const op of OPERATIONS) expect(op.method === "GET", op.operationId).toBe(op.access === "read"); + }); +}); + +describe("操作の記録へ写す鍵(決定 12)", () => { + it("すべての操作の鍵と作った識別子の項目が、許可した鍵の一覧にある", () => { + const allowed: readonly string[] = AUDIT_KEYS; + for (const op of OPERATIONS) { + for (const key of op.keys) expect(allowed, `${op.operationId} の ${key}`).toContain(key); + if (op.idField) expect(allowed, `${op.operationId} の ${op.idField}`).toContain(op.idField.split(".").at(-1)); + } + }); +}); + +describe("操作の表の生成", () => { + it("契約から読んだ操作と表が一致する", async () => { + expect((await readOperations()).map((o) => o.operationId)).toEqual(OPERATIONS.map((o) => o.operationId)); + }); + + it("経路を表から組み、鍵の query を繰り返す", () => { + expect(operationUrl(operation("getSprintRunDetail"), "a/1", { sprint_run_id: ["s1", "s2"] })).toBe( + "/accounts/a%2F1/sprint-runs/detail?sprint_run_id=s1&sprint_run_id=s2", + ); + expect(operationUrl(operation("getMe"), null)).toBe("/me"); + expect(() => operationUrl(operation("listLoops"), null)).toThrow(); + }); +}); diff --git a/factory/package-lock.json b/factory/package-lock.json index 9f99ff596..001c792b7 100644 --- a/factory/package-lock.json +++ b/factory/package-lock.json @@ -66,7 +66,9 @@ "ajv-formats": "^3.0.1" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^24.19.2", + "js-yaml": "^4.3.2", "json-schema-to-typescript": "^16.0.0", "typescript": "^7.0.2", "vitest": "^5.0.5" @@ -99,6 +101,36 @@ } } }, + "contracts/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "contracts/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "contracts/node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -2502,6 +2534,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", From 8890d548ff0d2b95324466a0050a94fdf4d7da86 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:33:44 +0000 Subject: [PATCH 14/39] =?UTF-8?q?API=20=E3=81=A8=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=81=AE=20API=20=E3=82=AF=E3=83=A9=E3=82=A4=E3=82=A2=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A1=A8=E3=81=8B?= =?UTF-8?q?=E3=82=89=E7=B5=84=E3=81=BF=E3=80=81=E9=85=8D=E5=88=97=E3=81=A7?= =?UTF-8?q?=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=99=EF=BC=88#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 操作の登録の層(routes/operation.ts)が、経路の登録・要素の数と重複の検査・経路のアカウントの解決・ 要素ごとの実行を、すべての利用者向けの操作に同じく掛ける。スプリントの実行・開発ループ・チームの作成に 作成の冪等の鍵を足し、鍵の行を作るものと同じトランザクションで書く。画面の API クライアントは要求を 操作の表から組み、詳細・工程の中身・バックログ・ループの詳細を要素の数によらず 1 回の要求で読む。 ソースの検査に、要求の経路を文字列で直に組む書き方を落とす規則を足した。 Co-Authored-By: Claude Opus 5.5 --- factory/api/src/accounts/memory-store.ts | 16 +- factory/api/src/accounts/service.ts | 25 ++- factory/api/src/accounts/store.ts | 26 ++- factory/api/src/auth/resolve.ts | 11 +- factory/api/src/auth/verify.test.ts | 2 +- factory/api/src/handlers/api.test.ts | 129 +++++++------- factory/api/src/idempotency.ts | 41 +++++ factory/api/src/routes/accounts.ts | 92 +++++----- factory/api/src/routes/body.ts | 13 -- factory/api/src/routes/elements.ts | 35 ++++ factory/api/src/routes/factory.test.ts | 183 +++++++++++++------- factory/api/src/routes/factory.ts | 178 ++++++++----------- factory/api/src/routes/me.ts | 19 +- factory/api/src/routes/operation.ts | 126 ++++++++++++++ factory/api/src/routes/sprint-views.test.ts | 104 +++++++---- factory/api/src/routes/sprint-views.ts | 117 ++++++------- factory/api/src/routes/teams.ts | 23 ++- factory/api/src/runs/control.ts | 25 ++- factory/api/src/runs/dynamo-table.ts | 31 +++- factory/api/src/runs/loops.ts | 21 ++- factory/api/src/runs/memory-table.ts | 20 ++- factory/api/src/runs/model.ts | 5 +- factory/api/src/runs/table.ts | 6 +- factory/api/src/testing/app.ts | 33 +++- factory/web/scripts/check-source.ts | 22 ++- factory/web/src/App.test.tsx | 44 ++--- factory/web/src/api.test.ts | 108 ++++++++++++ factory/web/src/api.ts | 135 +++++++++------ factory/web/src/checks.test.ts | 9 + 29 files changed, 1117 insertions(+), 482 deletions(-) create mode 100644 factory/api/src/idempotency.ts delete mode 100644 factory/api/src/routes/body.ts create mode 100644 factory/api/src/routes/elements.ts create mode 100644 factory/api/src/routes/operation.ts create mode 100644 factory/web/src/api.test.ts diff --git a/factory/api/src/accounts/memory-store.ts b/factory/api/src/accounts/memory-store.ts index 613568fe0..b3eb4d659 100644 --- a/factory/api/src/accounts/memory-store.ts +++ b/factory/api/src/accounts/memory-store.ts @@ -1,5 +1,6 @@ import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; // DynamoAccountStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う // beforeWrite は書き込みの直前に呼ばれる。投げると書き込みは何も残さない(トランザクションの失敗) @@ -8,6 +9,8 @@ export class MemoryAccountStore implements AccountStore { readonly accounts = new Map(); readonly seats = new Map(); readonly history: HistoryRecord[] = []; + // 作成の冪等の鍵の行(TTL で消さない) + readonly idempotency = new Map(); beforeWrite: ((op: string) => void | Promise) | null = null; private static seatKey(accountId: string, userId: string) { @@ -56,12 +59,21 @@ export class MemoryAccountStore implements AccountStore { return [...this.seats.values()].filter((s) => s.userId === userId).map((s) => s.accountId); } - async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) { + async getIdempotent(ref: IdempotencyRef, now: string) { + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } + + async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) { const key = MemoryAccountStore.seatKey(ownerSeat.accountId, ownerSeat.userId); + const idemFree = () => { + const row = idempotency && this.idempotency.get(idempotencyPk(idempotency)); + return !row || row.ttl < epochSeconds(idempotency!.createdAt); + }; await this.write( "createTeam", - () => !this.accounts.has(account.accountId) && !this.seats.has(key), + () => !this.accounts.has(account.accountId) && !this.seats.has(key) && idemFree(), () => { + if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); this.accounts.set(account.accountId, { ...account }); this.seats.set(key, { ...ownerSeat }); }, diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 241a98f96..85de493e0 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -25,6 +25,7 @@ import { type UserRecord, } from "./model.ts"; import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import { claimOf, type IdempotencyRef } from "../idempotency.ts"; // 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」) const VERSION_RETRIES = 3; @@ -161,17 +162,29 @@ export class AccountService { } // チームを作り、作った利用者を持ち主にして持ち主の席を 1 つ作る(受け入れ条件 9、E4) - async createTeam(caller: Caller, name: string, seatType: SeatType): Promise { + // idempotencyKey があれば、同じ利用者の同じ鍵の 24 時間以内の作成を 1 件にし、2 回目は 1 回目のチームを返す(#2384 の I14) + async createTeam(caller: Caller, name: string, seatType: SeatType, idempotencyKey?: string): Promise { await this.ensureUser(caller); const at = this.stamp(); + const ref: IdempotencyRef | undefined = idempotencyKey === undefined ? undefined : { userId: caller.userId, operationId: "createTeam", key: idempotencyKey }; + const replayed = ref ? await this.store.getIdempotent(ref, at) : null; + if (replayed) return this.resolveAccount(caller, replayed); const account = this.newAccount("team", "team", caller.userId, name, at); const seat: SeatRecord = { accountId: account.accountId, userId: caller.userId, seatType, addedAt: at }; try { - await this.store.createTeam(account, seat, [ - this.accountCreated(account, caller.userId, { name }), - this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }), - ]); - } catch { + await this.store.createTeam( + account, + seat, + [ + this.accountCreated(account, caller.userId, { name }), + this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }), + ], + ref && claimOf(ref, account.accountId, at), + ); + } catch (err) { + // 同じ鍵の作成が同時に走り、先に書いた方が勝った + const won = ref && err instanceof ConditionFailedError ? await this.store.getIdempotent(ref, at) : null; + if (won) return this.resolveAccount(caller, won); throw new AccountUnavailableError(); } return this.context(account, caller.userId, seatType); diff --git a/factory/api/src/accounts/store.ts b/factory/api/src/accounts/store.ts index d32b771e1..30e626b18 100644 --- a/factory/api/src/accounts/store.ts +++ b/factory/api/src/accounts/store.ts @@ -8,6 +8,7 @@ import { type TransactWriteCommandInput, } from "@aws-sdk/lib-dynamodb"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; // 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える export class ConditionFailedError extends Error { @@ -33,7 +34,10 @@ export interface AccountStore { // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う listMemberships(userId: string): Promise; - createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]): Promise; + // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14) + createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise; + // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う + getIdempotent(ref: IdempotencyRef, now: string): Promise; // 席が無いときだけ足す(I15) addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise; // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える @@ -232,14 +236,32 @@ export class DynamoAccountStore implements AccountStore { return out; } - async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[]) { + async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) { await this.transact([ this.put(accountItem(account), NOT_EXISTS), this.put(seatItem(ownerSeat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h))), + ...(idempotency + ? [ + { + Put: { + TableName: this.tableName, + Item: { pk: idempotencyPk(idempotency), sk: "IDEMP", createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl }, + ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) }, + }, + }, + ] + : []), ]); } + async getIdempotent(ref: IdempotencyRef, now: string) { + const item = await this.get({ pk: idempotencyPk(ref), sk: "IDEMP" }); + return liveCreatedId(item && { createdId: String(item.createdId), ttl: Number(item.ttl) }, now); + } + async addSeat(seat: SeatRecord, history: HistoryRecord[]) { await this.transact([this.put(seatItem(seat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h)))]); } diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts index ce4849191..297f218c1 100644 --- a/factory/api/src/auth/resolve.ts +++ b/factory/api/src/auth/resolve.ts @@ -8,7 +8,16 @@ export interface AuthDeps { accounts: AccountService; } -export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps } }; +// 操作の登録の層が、操作の記録へ渡すために置く値(#2384)。対象の ID は許可した鍵の値だけ +export interface OperationTrace { + operationId: string; + accountId: string | null; + targetIds: string[]; + // 失敗した要素の誤りの code(重ねない) + reasons: string[]; +} + +export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace } }; // 経路の前に token を毎回検証し、利用者を文脈に置く中間処理(E3)。 // #2171・#2186・#2174 の経路も同じ中間処理を通す diff --git a/factory/api/src/auth/verify.test.ts b/factory/api/src/auth/verify.test.ts index c1d7cb6a8..41fd4293b 100644 --- a/factory/api/src/auth/verify.test.ts +++ b/factory/api/src/auth/verify.test.ts @@ -37,7 +37,7 @@ describe("token の検証(受け入れ条件 4)", () => { it("正しい token は通り、IdP を username の頭から決める", async () => { const res = await api.call("GET", "/me", { user: "u-2", idp: "GitHub" }); expect(res.status).toBe(200); - expect(await res.json()).toMatchObject({ user_id: "u-2", idp: "github" }); + expect((await res.json())[0]).toMatchObject({ user_id: "u-2", idp: "github" }); }); it("ログには利用者の ID・経路・状態コードだけを出し、token を出さない", async () => { diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index b313eb9be..6c7bdca01 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -1,14 +1,15 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { createTestApi, type TestApi } from "../testing/app.ts"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; import { createApiApp } from "./api.ts"; let api: TestApi; +let keys = 0; beforeEach(async () => { api = await createTestApi(); }); async function me(user: string) { - return (await (await api.call("GET", "/me", { user })).json()) as { + return (await (await api.op("getMe", { user })).json())[0] as { user_id: string; personal_account_id: string; accounts: { account_id: string; kind: string; plan: string; role: string; seat_type: string | null; execution_mode: string }[]; @@ -17,14 +18,20 @@ async function me(user: string) { async function team(owner: string, seat_type = "standard") { await me(owner); - const res = await api.call("POST", "/teams", { user: owner, body: { name: "チーム", seat_type } }); - expect(res.status).toBe(201); - return (await res.json()) as { account_id: string; seats: { user_id: string; seat_type: string }[] }; + const { status, body } = await firstResult(await api.op("createTeam", { user: owner, items: [{ name: "チーム", seat_type, idempotency_key: `k-${++keys}` }] })); + expect(status).toBe(200); + return body as { account_id: string; seats: { user_id: string; seat_type: string }[] }; } const seatsOf = (accountId: string) => api.store.listSeats(accountId); +const addSeat = (user: string, accountId: string, user_id: string, seat_type: string) => + api.op("addSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult); +const changeSeat = (user: string, accountId: string, user_id: string, seat_type: string) => + api.op("changeSeat", { user, accountId, items: [{ user_id, seat_type }] }).then(firstResult); +const removeSeat = (user: string, accountId: string, user_id: string) => api.op("removeSeat", { user, accountId, query: { user_id: [user_id] } }).then(firstResult); +const getAccount = (user: string, id: string) => api.op("getAccount", { user, query: { account_id: [id] } }).then(firstResult); -describe("GET /me(E2、受け入れ条件 12・18、I24)", () => { +describe("getMe(E2、受け入れ条件 12・18、I24)", () => { it("初回は Free・local の個人のアカウントを 1 つ作る", async () => { const body = await me("u-1"); expect(body.accounts).toEqual([ @@ -43,27 +50,26 @@ describe("GET /me(E2、受け入れ条件 12・18、I24)", () => { api.store.beforeWrite = () => { throw new Error("down"); }; - const res = await api.call("GET", "/me", { user: "u-1" }); + const res = await api.op("getMe", { user: "u-1" }); expect(res.status).toBe(503); expect((await res.json()).error.code).toBe("account_unavailable"); api.store.beforeWrite = null; - expect((await api.call("GET", "/me", { user: "u-1" })).status).toBe(200); + expect((await api.op("getMe", { user: "u-1" })).status).toBe(200); }); it("席を持つチームが一覧に出る", async () => { const t = await team("owner"); await me("u-1"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "premium" } }); + await addSeat("owner", t.account_id, "u-1", "premium"); const body = await me("u-1"); expect(body.accounts[1]).toMatchObject({ account_id: t.account_id, kind: "team", plan: "team", role: "member", seat_type: "premium" }); }); }); -describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", () => { +describe("getAccount(受け入れ条件 5・6・8・18)", () => { it("個人のアカウントは上限と形態を返し、席を返さない", async () => { const { personal_account_id } = await me("u-1"); - const res = await api.call("GET", `/accounts/${personal_account_id}`, { user: "u-1" }); - expect(await res.json()).toEqual({ + expect((await getAccount("u-1", personal_account_id)).body).toEqual({ account_id: personal_account_id, kind: "personal", name: null, @@ -78,11 +84,9 @@ describe("GET /accounts/{account_id}(受け入れ条件 5・6・8・18)", () it("チームは席の種類で上限が決まり、managed のときだけ月の token を返す", async () => { const t = await team("owner", "premium"); - let res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" }); - expect((await res.json()).limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null }); - await api.call("PUT", `/accounts/${t.account_id}/execution-mode`, { user: "owner", body: { execution_mode: "managed" } }); - res = await api.call("GET", `/accounts/${t.account_id}`, { user: "owner" }); - const body = await res.json(); + expect((await getAccount("owner", t.account_id)).body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: null }); + await api.op("setExecutionMode", { user: "owner", items: [{ account_id: t.account_id, execution_mode: "managed" }] }); + const { body } = await getAccount("owner", t.account_id); expect(body.limits).toEqual({ concurrent_sprint_runs: 10, monthly_tokens: 125_000_000 }); expect(body.seats).toEqual([{ user_id: "owner", idp: "google", seat_type: "premium", added_at: expect.any(String) }]); }); @@ -96,16 +100,17 @@ describe("他人のアカウント(受け入れ条件 13、I18)", () => { const targets = [other.personal_account_id, t.account_id, "00000000-0000-4000-8000-999999999999"]; const bodies = new Set(); for (const id of targets) { - for (const [method, path, body] of [ - ["GET", `/accounts/${id}`, undefined], - ["PUT", `/accounts/${id}/execution-mode`, { execution_mode: "byok" }], - ["POST", `/accounts/${id}/seats`, { user_id: "a", seat_type: "standard" }], - ["PATCH", `/accounts/${id}/seats/owner`, { seat_type: "premium" }], - ["DELETE", `/accounts/${id}/seats/owner`, undefined], + // アカウントそのものの操作は要素の forbidden、アカウントに属するものの操作は要求全体の 403。本文は同じ + for (const [name, call] of [ + ["getAccount", () => getAccount("a", id)], + ["setExecutionMode", () => api.op("setExecutionMode", { user: "a", items: [{ account_id: id, execution_mode: "byok" }] }).then(firstResult)], + ["addSeat", () => addSeat("a", id, "a", "standard")], + ["changeSeat", () => changeSeat("a", id, "owner", "premium")], + ["removeSeat", () => removeSeat("a", id, "owner")], ] as const) { - const res = await api.call(method, path, { user: "a", body }); - expect(res.status, `${method} ${path}`).toBe(403); - bodies.add(await res.text()); + const { status, body } = await call(); + expect(status, name).toBe(403); + bodies.add(JSON.stringify(body)); } } expect(bodies.size).toBe(1); @@ -120,25 +125,31 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () expect(t.seats).toEqual([expect.objectContaining({ user_id: "owner", seat_type: "premium" })]); }); + it("同じ冪等の鍵の 2 回目は 1 回目のチームを返し、チームは 1 つだけ(#2384 の I14)", async () => { + await me("owner"); + const create = () => api.op("createTeam", { user: "owner", items: [{ name: "チーム", seat_type: "standard", idempotency_key: "same" }] }).then(firstResult); + const first = await create(); + const second = await create(); + expect(second.body.account_id).toBe(first.body.account_id); + expect([...api.store.accounts.values()].filter((a) => a.kind === "team")).toHaveLength(1); + }); + it.each([ [{ name: "", seat_type: "standard" }], [{ name: "あ".repeat(65), seat_type: "standard" }], [{ name: "x", seat_type: "gold" }], ])("名前か席の種類が違えば 400: %j", async (body) => { await me("owner"); - expect((await api.call("POST", "/teams", { user: "owner", body })).status).toBe(400); + expect((await firstResult(await api.op("createTeam", { user: "owner", items: [{ ...body, idempotency_key: "k-400" }] }))).status).toBe(400); }); it("持ち主は席を足し、種類を変え、外せる。そのたびに履歴が 1 つ増える", async () => { const t = await team("owner"); await me("u-1"); const before = api.store.history.length; - let res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); - expect(res.status).toBe(201); - res = await api.call("PATCH", `/accounts/${t.account_id}/seats/u-1`, { user: "owner", body: { seat_type: "premium" } }); - expect(await res.json()).toMatchObject({ user_id: "u-1", seat_type: "premium" }); - res = await api.call("DELETE", `/accounts/${t.account_id}/seats/u-1`, { user: "owner" }); - expect(res.status).toBe(204); + expect((await addSeat("owner", t.account_id, "u-1", "standard")).status).toBe(200); + expect((await changeSeat("owner", t.account_id, "u-1", "premium")).body).toMatchObject({ user_id: "u-1", seat_type: "premium" }); + expect(await removeSeat("owner", t.account_id, "u-1")).toEqual({ status: 200, body: { removed: true } }); expect(api.store.history.slice(before).map((h) => h.type)).toEqual(["seat.added", "seat.changed", "seat.removed"]); expect(await seatsOf(t.account_id)).toHaveLength(1); }); @@ -147,16 +158,16 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () const t = await team("owner"); await me("member"); await me("u-2"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "member", "standard"); const seats = await seatsOf(t.account_id); const before = api.store.history.length; - for (const [method, path, body] of [ - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-2", seat_type: "standard" }], - ["PATCH", `/accounts/${t.account_id}/seats/owner`, { seat_type: "premium" }], - ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined], - ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }], - ] as const) { - expect((await api.call(method, path, { user: "member", body })).status).toBe(403); + for (const result of [ + await addSeat("member", t.account_id, "u-2", "standard"), + await changeSeat("member", t.account_id, "owner", "premium"), + await removeSeat("member", t.account_id, "owner"), + await changeSeat("member", t.account_id, "nobody", "premium"), + ]) { + expect(result.status).toBe(403); } expect(await seatsOf(t.account_id)).toEqual(seats); expect(api.store.history.length).toBe(before); @@ -165,20 +176,20 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () it("未ログイン → 422、既に席がある → 409、持ち主の席を外す → 409。席も履歴も変わらない", async () => { const t = await team("owner"); await me("u-1"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "u-1", "standard"); const seats = await seatsOf(t.account_id); const before = api.store.history.length; const cases = [ - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "never", seat_type: "standard" }, 422, "user_not_found"], - ["POST", `/accounts/${t.account_id}/seats`, { user_id: "u-1", seat_type: "premium" }, 409, "seat_exists"], - ["DELETE", `/accounts/${t.account_id}/seats/owner`, undefined, 409, "owner_seat_protected"], - ["PATCH", `/accounts/${t.account_id}/seats/nobody`, { seat_type: "premium" }, 404, "seat_not_found"], - ["DELETE", `/accounts/${t.account_id}/seats/nobody`, undefined, 404, "seat_not_found"], + [() => addSeat("owner", t.account_id, "never", "standard"), 422, "user_not_found"], + [() => addSeat("owner", t.account_id, "u-1", "premium"), 409, "seat_exists"], + [() => removeSeat("owner", t.account_id, "owner"), 409, "owner_seat_protected"], + [() => changeSeat("owner", t.account_id, "nobody", "premium"), 404, "seat_not_found"], + [() => removeSeat("owner", t.account_id, "nobody"), 404, "seat_not_found"], ] as const; - for (const [method, path, body, status, code] of cases) { - const res = await api.call(method, path, { user: "owner", body }); + for (const [call, status, code] of cases) { + const res = await call(); expect(res.status, code).toBe(status); - expect((await res.json()).error).toEqual({ code, message: expect.any(String) }); + expect(res.body.error).toEqual({ code, message: expect.any(String) }); } expect(await seatsOf(t.account_id)).toEqual(seats); expect(api.store.history.length).toBe(before); @@ -187,9 +198,9 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () it("個人のアカウントへの席の操作は 409 not_a_team", async () => { const { personal_account_id } = await me("u-1"); await me("u-2"); - const res = await api.call("POST", `/accounts/${personal_account_id}/seats`, { user: "u-1", body: { user_id: "u-2", seat_type: "standard" } }); + const res = await addSeat("u-1", personal_account_id, "u-2", "standard"); expect(res.status).toBe(409); - expect((await res.json()).error.code).toBe("not_a_team"); + expect(res.body.error.code).toBe("not_a_team"); }); it("書き込みが失敗したら席も履歴も増えない", async () => { @@ -199,7 +210,7 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () api.store.beforeWrite = () => { throw new Error("down"); }; - const res = await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "u-1", seat_type: "standard" } }); + const res = await addSeat("owner", t.account_id, "u-1", "standard"); expect(res.status).toBe(503); expect(api.store.history.length).toBe(before); expect(await seatsOf(t.account_id)).toHaveLength(1); @@ -208,14 +219,14 @@ describe("チームと席(受け入れ条件 9〜11、I13〜I17・I20)", () describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { const put = (user: string, id: string, execution_mode: unknown) => - api.call("PUT", `/accounts/${id}/execution-mode`, { user, body: { execution_mode } }); + api.op("setExecutionMode", { user, items: [{ account_id: id, execution_mode }] }).then(firstResult); it("持ち主は形態を変えられ、履歴が 1 つ増える。同じ値なら増えない", async () => { const { personal_account_id: id } = await me("u-1"); const before = api.store.history.length; const res = await put("u-1", id, "byok"); expect(res.status).toBe(200); - expect((await res.json()).execution_mode).toBe("byok"); + expect(res.body.execution_mode).toBe("byok"); expect((await put("u-1", id, "byok")).status).toBe(200); expect(api.store.history.slice(before).map((h) => [h.type, h.before, h.after])).toEqual([["execution_mode.changed", "local", "byok"]]); }); @@ -225,7 +236,7 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { const before = api.store.history.length; const res = await put("u-1", id, "managed"); expect(res.status).toBe(409); - expect((await res.json()).error).toEqual({ + expect(res.body.error).toEqual({ code: "managed_not_allowed_on_free", message: "Free の料金プランでは managed(サービスの Bedrock)を選べません", }); @@ -236,16 +247,16 @@ describe("実行の形態(受け入れ条件 18・19、I21・I22)", () => { it("Pro なら managed を選べる", async () => { const { personal_account_id: id } = await me("u-1"); await api.service.setPlan(id, "pro"); - expect((await (await put("u-1", id, "managed")).json()).limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 }); + expect((await put("u-1", id, "managed")).body.limits).toEqual({ concurrent_sprint_runs: 3, monthly_tokens: 20_000_000 }); }); it("チームの持ち主でない席の利用者は 403 で、形態は変わらない", async () => { const t = await team("owner"); await me("member"); - await api.call("POST", `/accounts/${t.account_id}/seats`, { user: "owner", body: { user_id: "member", seat_type: "standard" } }); + await addSeat("owner", t.account_id, "member", "standard"); const res = await put("member", t.account_id, "byok"); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }); + expect(res.body).toEqual({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }); expect(api.store.accounts.get(t.account_id)?.executionMode).toBe("local"); }); diff --git a/factory/api/src/idempotency.ts b/factory/api/src/idempotency.ts new file mode 100644 index 000000000..9fa7857fe --- /dev/null +++ b/factory/api/src/idempotency.ts @@ -0,0 +1,41 @@ +// 作成の冪等の鍵(#2384 の決定 4・不変条件 I14)。新しい識別子を作る作成で、同じ利用者・同じ操作・同じ鍵の +// 24 時間以内の作成を 1 件にする。鍵の行は作るものと同じ表へ同じトランザクションで書く +import { InvalidRequestError } from "./errors.ts"; + +export const IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60; +const KEY_PATTERN = /^[0-9A-Za-z-]{1,64}$/; + +// 鍵の行を引くための 3 つの値 +export interface IdempotencyRef { + userId: string; + operationId: string; + key: string; +} + +// 鍵の行に書く値。ttl は作った日時の 24 時間後(UNIX 秒) +export interface IdempotencyClaim extends IdempotencyRef { + createdId: string; + createdAt: string; + ttl: number; +} + +export function parseIdempotencyKey(raw: unknown): string { + if (typeof raw !== "string" || !KEY_PATTERN.test(raw)) { + throw new InvalidRequestError("作成の冪等の鍵は 1〜64 字の英数字とハイフンです", "invalid_idempotency_key"); + } + return raw; +} + +export const idempotencyPk = (ref: IdempotencyRef) => `IDEMP#${ref.userId}#${ref.operationId}#${ref.key}`; + +export const epochSeconds = (iso: string) => Math.floor(Date.parse(iso) / 1000); + +export function claimOf(ref: IdempotencyRef, createdId: string, createdAt: string): IdempotencyClaim { + return { ...ref, createdId, createdAt, ttl: epochSeconds(createdAt) + IDEMPOTENCY_TTL_SECONDS }; +} + +// 期限は TTL の削除に頼らず、読むたびに今の時刻と比べる(DynamoDB の TTL は期限を過ぎた行をすぐには消さない) +export function liveCreatedId(row: { createdId: string; ttl: number } | null, nowIso: string): string | null { + if (!row) return null; + return row.ttl > epochSeconds(nowIso) ? row.createdId : null; +} diff --git a/factory/api/src/routes/accounts.ts b/factory/api/src/routes/accounts.ts index d08c50839..30b594bb5 100644 --- a/factory/api/src/routes/accounts.ts +++ b/factory/api/src/routes/accounts.ts @@ -1,57 +1,69 @@ -import type { Hono } from "hono"; +import type { Context, Hono } from "hono"; import { ForbiddenError, InvalidRequestError } from "../errors.ts"; -import { isExecutionMode, isSeatType } from "../accounts/model.ts"; +import { isExecutionMode, isSeatType, type AccountContext } from "../accounts/model.ts"; import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; -import { readJson } from "./body.ts"; +import { registerOperation } from "./operation.ts"; import { accountDetail, seatView } from "./views.ts"; const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; +function userIdOf(raw: unknown): string { + if (typeof raw !== "string" || raw === "" || raw.length > 128) throw new InvalidRequestError("利用者の ID の形ではありません"); + return raw; +} + +// アカウントそのものの操作は、要素がアカウントのため要素ごとに解決する(#2384 の決定 2)。解決できない要素は forbidden +function accountOf(c: Context, raw: unknown): Promise { + if (typeof raw !== "string" || raw === "") throw new ForbiddenError(); + return resolveFromRequest(c, raw); +} + export function registerAccounts(app: Hono): void { - app.get("/accounts/:accountId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const accounts = c.get("auth").accounts; - const account = await accounts.getAccount(ctx.accountId); - if (!account) throw new ForbiddenError(); - const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; - return c.json(accountDetail(ctx, account, seats)); + registerOperation(app, "getAccount", { + run: async (e, _ctx, _s, c) => { + const ctx = await accountOf(c, e.account_id); + const accounts = c.get("auth").accounts; + const account = await accounts.getAccount(ctx.accountId); + if (!account) throw new ForbiddenError(); + const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; + return accountDetail(ctx, account, seats); + }, }); - app.put("/accounts/:accountId/execution-mode", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (!isExecutionMode(body.execution_mode)) { - throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです"); - } - const accounts = c.get("auth").accounts; - const account = await accounts.setExecutionMode(ctx, body.execution_mode); - const next = { ...ctx, executionMode: account.executionMode, plan: account.plan }; - const seats = next.kind === "team" ? await accounts.listSeats(next) : null; - return c.json(accountDetail(next, account, seats)); + registerOperation(app, "setExecutionMode", { + run: async (e, _ctx, _s, c) => { + const ctx = await accountOf(c, e.account_id); + if (!isExecutionMode(e.execution_mode)) { + throw new InvalidRequestError("実行の形態は local・byok・managed のどれかです"); + } + const accounts = c.get("auth").accounts; + const account = await accounts.setExecutionMode(ctx, e.execution_mode); + const next = { ...ctx, executionMode: account.executionMode, plan: account.plan }; + const seats = next.kind === "team" ? await accounts.listSeats(next) : null; + return accountDetail(next, account, seats); + }, }); - app.post("/accounts/:accountId/seats", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (typeof body.user_id !== "string" || body.user_id === "" || body.user_id.length > 128) { - throw new InvalidRequestError("利用者の ID の形ではありません"); - } - if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - const seat = await c.get("auth").accounts.addSeat(ctx, body.user_id, body.seat_type); - return c.json(seatView(seat), 201); + registerOperation(app, "addSeat", { + run: async (e, ctx, _s, c) => { + const userId = userIdOf(e.user_id); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, e.seat_type)); + }, }); - app.patch("/accounts/:accountId/seats/:userId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - const body = await readJson(c); - if (!isSeatType(body.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - const seat = await c.get("auth").accounts.changeSeat(ctx, c.req.param("userId"), body.seat_type); - return c.json(seatView(seat)); + registerOperation(app, "changeSeat", { + run: async (e, ctx, _s, c) => { + const userId = userIdOf(e.user_id); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, e.seat_type)); + }, }); - app.delete("/accounts/:accountId/seats/:userId", async (c) => { - const ctx = await resolveFromRequest(c, c.req.param("accountId")); - await c.get("auth").accounts.removeSeat(ctx, c.req.param("userId")); - return c.body(null, 204); + registerOperation(app, "removeSeat", { + run: async (e, ctx, _s, c) => { + await c.get("auth").accounts.removeSeat(ctx!, userIdOf(e.user_id)); + return { removed: true }; + }, }); } diff --git a/factory/api/src/routes/body.ts b/factory/api/src/routes/body.ts deleted file mode 100644 index a32bd9983..000000000 --- a/factory/api/src/routes/body.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Context } from "hono"; -import { InvalidRequestError } from "../errors.ts"; - -export async function readJson(c: Context): Promise> { - let body: unknown; - try { - body = await c.req.json(); - } catch { - throw new InvalidRequestError(); - } - if (typeof body !== "object" || body === null || Array.isArray(body)) throw new InvalidRequestError(); - return body as Record; -} diff --git a/factory/api/src/routes/elements.ts b/factory/api/src/routes/elements.ts new file mode 100644 index 000000000..eae8fcb43 --- /dev/null +++ b/factory/api/src/routes/elements.ts @@ -0,0 +1,35 @@ +// 要素ごとの実行(#2384 の決定 3・不変条件 I13)。要素を並べる数 10 で順にユースケースへ渡し、 +// ApiError を要素の結果に包む。応答は入力と同じ順・同じ数で、失敗の要素はその要素のデータを持たない +import { ApiError } from "../errors.ts"; + +// 要素の読み出しを並べる数の上限(一覧の行の読み出しと同じ値) +export const ELEMENT_CONCURRENCY = 10; + +export type ElementResult = + | { ok: true; value: unknown } + | { ok: false; status: number; error: { code: string; message: string; details?: Record } }; + +export async function mapLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i]!, i); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +} + +// ApiError でない失敗(保存の不調など)はそのまま投げ、要求全体の 503 にする +export function runElements(elements: E[], fn: (element: E) => Promise): Promise { + return mapLimit(elements, ELEMENT_CONCURRENCY, async (element) => { + try { + return { ok: true as const, value: await fn(element) }; + } catch (err) { + if (!(err instanceof ApiError)) throw err; + return { ok: false as const, status: err.status, error: err.body().error }; + } + }); +} diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index 60de82223..7655d639a 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -9,7 +9,8 @@ import { CANDIDATE_FALLBACK_NOTE, type CandidateModel } from "../runs/candidates import { LoopControl } from "../runs/loops.ts"; import { createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createTokenKit, type TokenKit } from "../testing/tokens.ts"; -import type { TestResponse } from "../testing/app.ts"; +import type { OperationId } from "@ndf-factory/contracts/operations"; +import { firstResult, opRequest, type OpOptions, type TestResponse } from "../testing/app.ts"; const API_KEY = "sk-ant-test-SECRET-VALUE-0001"; @@ -52,8 +53,17 @@ async function call(method: string, path: string, user: string, body?: unknown): return res; } +// 操作を呼び、要素 1 個の結果を状態と本文で読む(#2384) +async function op(operationId: OperationId, user: string, opts: Omit = {}): Promise<{ status: number; body: any }> { + const { method, path, body } = opRequest(operationId, opts); + return firstResult(await call(method, path, user, body)); +} + +let keys = 0; +const idem = () => `k-${++keys}`; + async function personal(user: string): Promise { - return ((await (await call("GET", "/me", user)).json()) as { personal_account_id: string }).personal_account_id; + return (await op("getMe", user)).body.personal_account_id; } beforeEach(async () => { @@ -88,97 +98,145 @@ beforeEach(async () => { }); async function candidates(accountId: string, user = "u-1") { - const url = (await (await call("GET", `/accounts/${accountId}/github/authorize-url`, user)).json()).url as string; + const url = (await op("getAuthorizeUrl", user, { accountId })).body.url as string; const state = new URL(url).searchParams.get("state"); - return call("POST", `/accounts/${accountId}/github/candidates`, user, { code: "code-1", state }); + return op("postRepositoryCandidates", user, { accountId, items: [{ code: "code-1", state }] }); } async function register(accountId: string, repositoryId = 101) { - const { proof } = await (await candidates(accountId)).json(); - return call("POST", `/accounts/${accountId}/repositories`, "u-1", { proof, repository_id: repositoryId }); + const { proof } = (await candidates(accountId)).body; + return op("registerRepository", "u-1", { accountId, items: [{ proof, repository_id: repositoryId }] }); } describe("取り込み(受け入れ条件 1〜5、I32・I33・I36)", () => { it("導入の案内と、利用者の届くリポジトリだけの候補が出る(AC1)", async () => { const acc = await personal("u-1"); - expect((await (await call("GET", `/accounts/${acc}/github/install-url`, "u-1")).json()).url).toContain("/installations/new"); - const body = await (await candidates(acc)).json(); + expect((await op("getInstallUrl", "u-1", { accountId: acc })).body.url).toContain("/installations/new"); + const { body } = await candidates(acc); expect(body.candidates.map((c: { repository_id: number }) => c.repository_id).sort()).toEqual([101, 102]); }); it("state の利用者が違えば 400(決定 12)", async () => { const acc = await personal("u-1"); - const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", { code: "code-1", state: "x" }); + expect((await op("postRepositoryCandidates", "u-1", { accountId: acc, items: [{ code: "code-1", state: "x" }] })).status).toBe(400); + }); + + it("鍵を持たない操作に 2 個の要素を送ると、どれも処理せずに 400 duplicate_key", async () => { + const acc = await personal("u-1"); + const res = await call("POST", `/accounts/${acc}/github/candidates`, "u-1", [{ code: "code-1", state: "x" }, { code: "code-1", state: "y" }]); expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe("duplicate_key"); }); it("証明に無い ID・導入が届かない登録は 403 で行が増えない(AC2・I33)", async () => { const acc = await personal("u-1"); - const { proof } = await (await candidates(acc)).json(); - expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 201 })).status).toBe(403); + const { proof } = (await candidates(acc)).body; + expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 201 }] })).status).toBe(403); h.github.installations.get(9)!.delete(102); - expect((await call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: 102 })).status).toBe(403); + expect((await op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: 102 }] })).status).toBe(403); expect(h.table.registrations.size).toBe(0); }); it("同じリポジトリの 2 回目と同時の 2 本は 409 で 1 行(AC3・I32)", async () => { const acc = await personal("u-1"); - expect((await register(acc)).status).toBe(201); + expect((await register(acc)).status).toBe(200); expect((await register(acc)).status).toBe(409); - const { proof } = await (await candidates(acc)).json(); - const both = await Promise.all([102, 102].map((id) => call("POST", `/accounts/${acc}/repositories`, "u-1", { proof, repository_id: id }))); - expect(both.map((r) => r.status).sort()).toEqual([201, 409]); + const { proof } = (await candidates(acc)).body; + const both = await Promise.all([102, 102].map((id) => op("registerRepository", "u-1", { accountId: acc, items: [{ proof, repository_id: id }] }))); + expect(both.map((r) => r.status).sort()).toEqual([200, 409]); expect(h.table.registrations.size).toBe(2); }); - it("終わりの状態でない実行があると外せず 409、終われば 204(AC4・I36)", async () => { + it("終わりの状態でない実行があると外せず 409、終われば外せる(AC4・I36)", async () => { const acc = await personal("u-1"); await register(acc); h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }]); - const started = await call("POST", `/accounts/${acc}/sprint-runs`, "u-1", { repository_id: 101, issues: [1] }); - expect(started.status).toBe(201); - const { sprint_run_id } = await started.json(); + const started = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: idem() }] }); + expect(started.status).toBe(200); + const { sprint_run_id } = started.body; const list = await (await call("GET", `/accounts/${acc}/repositories`, "u-1")).json(); - expect(list.repositories[0].active_sprint_runs).toBe(1); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(409); - expect((await call("POST", `/accounts/${acc}/sprint-runs/${sprint_run_id}/cancel`, "u-1")).status).toBe(200); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(204); - expect((await call("DELETE", `/accounts/${acc}/repositories/101`, "u-1")).status).toBe(404); + expect(list[0].active_sprint_runs).toBe(1); + const remove = () => op("removeRepository", "u-1", { accountId: acc, query: { repository_id: [101] } }); + expect((await remove()).status).toBe(409); + expect((await op("cancelSprintRun", "u-1", { accountId: acc, items: [{ sprint_run_id }] })).status).toBe(200); + expect(await remove()).toEqual({ status: 200, body: { removed: true } }); + expect((await remove()).status).toBe(404); }); it("他人のアカウントの一覧・登録・外しは #2170 と同じ 403 の本文(AC5)", async () => { const acc = await personal("u-1"); await personal("u-2"); const forbidden = { error: { code: "forbidden", message: "このアカウントを見る権限がありません" } }; - for (const [method, path, body] of [ - ["GET", `/accounts/${acc}/repositories`, undefined], - ["POST", `/accounts/${acc}/repositories`, { proof: "x", repository_id: 101 }], - ["DELETE", `/accounts/${acc}/repositories/101`, undefined], - ] as const) { - const res = await call(method, path, "u-2", body); - expect(res.status).toBe(403); - expect(await res.json()).toEqual(forbidden); + for (const result of [ + await op("listRepositories", "u-2", { accountId: acc }), + await op("registerRepository", "u-2", { accountId: acc, items: [{ proof: "x", repository_id: 101 }] }), + await op("removeRepository", "u-2", { accountId: acc, query: { repository_id: [101] } }), + ]) { + expect(result).toEqual({ status: 403, body: forbidden }); } }); }); +describe("作成の冪等の鍵(#2384 の受け入れ条件 9・I14)", () => { + async function ready() { + const acc = await personal("u-1"); + await register(acc); + h.github.openIssues.set("org-a/repo-a", [{ number: 1, title: "a" }, { number: 2, title: "b" }]); + return acc; + } + + it("同じ鍵の 2 回目は 1 件だけ作り、同じ識別子を返す", async () => { + const acc = await ready(); + const item = { repository_id: 101, issues: [1], idempotency_key: "same-key" }; + const first = await op("startSprintRun", "u-1", { accountId: acc, items: [item] }); + const second = await op("startSprintRun", "u-1", { accountId: acc, items: [item] }); + expect(second.body.sprint_run_id).toBe(first.body.sprint_run_id); + expect(h.table.runs.size).toBe(1); + }); + + it("違う作成の操作へ同じ鍵を送ると、それぞれ作る", async () => { + const acc = await ready(); + await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "shared" }] }); + const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "shared" }] }); + expect(loop.status).toBe(200); + expect(h.table.loops.size).toBe(1); + expect(h.table.runs.size).toBeGreaterThanOrEqual(1); + }); + + it("鍵の行の期限(24 時間)を過ぎた同じ鍵は、行が残っていても新しく作る", async () => { + const acc = await ready(); + const first = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "old" }] }); + // 1 本目を終わらせ、同時の実行の数の上限に当たらないようにする + await h.table.finishRun(first.body.sprint_run_id, "done", null, h.clock.now.toISOString()); + h.clock.now = new Date(h.clock.now.getTime() + 24 * 60 * 60 * 1000 + 1000); + const second = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "old" }] }); + expect(second.status).toBe(200); + expect(second.body.sprint_run_id).not.toBe(first.body.sprint_run_id); + expect(h.table.idempotency.size).toBe(1); + }); + + it("鍵の形が違えば要素だけを 400 にする", async () => { + const acc = await ready(); + expect((await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "空白 あり" }] })).status).toBe(400); + expect(h.table.runs.size).toBe(0); + }); +}); + describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => { + const put = (acc: string, item: unknown) => op("putModelCredential", "u-1", { accountId: acc, items: [item] }); + it("2 種類の保存・差し替え・消去ができ、応答は 4 項目だけで値と ARN を返さない(AC6・AC7)", async () => { const acc = await personal("u-1"); - const saved = await (await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY })).json(); + const saved = (await put(acc, { kind: "anthropic_api_key", api_key: API_KEY })).body; expect(Object.keys(saved).sort()).toEqual(["external_id", "kind", "registered", "updated_at"]); expect(saved).toMatchObject({ registered: true, kind: "anthropic_api_key" }); - const role = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { - kind: "aws_role", - role_arn: "arn:aws:iam::123456789012:role/ndf", - region: "us-east-1", - }); - expect((await role.json()).kind).toBe("aws_role"); + const role = await put(acc, { kind: "aws_role", role_arn: "arn:aws:iam::123456789012:role/ndf", region: "us-east-1" }); + expect(role.body.kind).toBe("aws_role"); expect(secrets.values.size).toBe(1); expect(JSON.parse([...secrets.values.values()][0]!)).toMatchObject({ external_id: `ndf-factory:${acc}` }); - expect((await call("DELETE", `/accounts/${acc}/model-credential`, "u-1")).status).toBe(204); + expect(await op("deleteModelCredential", "u-1", { accountId: acc })).toEqual({ status: 200, body: { removed: true } }); expect(secrets.values.size).toBe(0); - expect(await (await call("GET", `/accounts/${acc}/model-credential`, "u-1")).json()).toEqual({ + expect((await op("getModelCredential", "u-1", { accountId: acc })).body).toEqual({ registered: false, kind: null, updated_at: null, @@ -190,30 +248,33 @@ describe("キーの持ち込み(受け入れ条件 6〜8、I38)", () => { }); it("外部 ID はアカウントごとに違う(AC8)", async () => { - const a = await (await call("GET", `/accounts/${await personal("u-1")}/model-credential`, "u-1")).json(); - const b = await (await call("GET", `/accounts/${await personal("u-2")}/model-credential`, "u-2")).json(); + const a = (await op("getModelCredential", "u-1", { accountId: await personal("u-1") })).body; + const b = (await op("getModelCredential", "u-2", { accountId: await personal("u-2") })).body; expect(a.external_id).not.toBe(b.external_id); }); it("形の違う入力は 400、置き場に書けなければ 503", async () => { const acc = await personal("u-1"); - expect((await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400); + expect((await put(acc, { kind: "aws_role", role_arn: "x", region: "us-east-1" })).status).toBe(400); secrets.fail = true; - const res = await call("PUT", `/accounts/${acc}/model-credential`, "u-1", { kind: "anthropic_api_key", api_key: API_KEY }); + const res = await put(acc, { kind: "anthropic_api_key", api_key: API_KEY }); expect(res.status).toBe(503); - expect((await res.json()).error.code).toBe("credential_unavailable"); + expect(res.body.error.code).toBe("credential_unavailable"); }); }); describe("課題の候補(受け入れ条件 9・10)", () => { + const ask = (acc: string, exclude?: number[]) => + op("postIssueCandidates", "u-1", { accountId: acc, items: [{ repository_id: 101, ...(exclude ? { exclude } : {}) }] }).then((r) => r.body); + it("順位の表があれば理由つきの候補、選び直しで外した番号は出ない(AC9・AC10)", async () => { const acc = await personal("u-1"); await register(acc); h.github.rankings.set("org-a/repo-a", { milestone: "03 Factory", rows: [{ number: 5, title: "e" }, { number: 6, title: "f" }] }); - const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json(); + const res = await ask(acc); expect(res).toMatchObject({ source: "ranking", milestone: "03 Factory", note: null }); expect(res.candidates.every((c: { reason: string }) => c.reason !== "")).toBe(true); - const again = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", { exclude: [5] })).json(); + const again = await ask(acc, [5]); expect(again.candidates.map((c: { number: number }) => c.number)).toEqual([6]); }); @@ -222,7 +283,7 @@ describe("課題の候補(受け入れ条件 9・10)", () => { await register(acc); h.github.openIssues.set("org-a/repo-a", [{ number: 9, title: "i" }, { number: 8, title: "h" }]); model.fn = () => Promise.reject(new Error("ThrottlingException")); - const res = await (await call("POST", `/accounts/${acc}/repositories/101/issue-candidates`, "u-1", {})).json(); + const res = await ask(acc); expect(res.source).toBe("recent"); expect(res.note).toBe(CANDIDATE_FALLBACK_NOTE); expect(res.candidates.map((c: { number: number }) => c.number)).toEqual([9, 8]); @@ -230,24 +291,24 @@ describe("課題の候補(受け入れ条件 9・10)", () => { }); describe("Factory のオーガナイザーの経路(#2439 の AC 9・11、I66)", () => { - it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 409、承認の依頼へ答える経路は無い", async () => { + it("一覧と履歴は GET で読め、止まっていない配布を解く操作は 404、承認の依頼へ答える経路は無い", async () => { const acc = await personal("u-1"); await register(acc); - const list = await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-1"); - expect(list.status).toBe(200); - expect(await list.json()).toEqual({ organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] }); - const history = await call("GET", `/accounts/${acc}/repositories/101/releases?limit=5`, "u-1"); - expect(await history.json()).toEqual({ items: [] }); - expect((await call("GET", `/accounts/${acc}/repositories/101/releases?limit=0`, "u-1")).status).toBe(400); - expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "reject" })).status).toBe(404); - expect((await call("POST", `/accounts/${acc}/repositories/101/releases/1/resolve`, "u-1", { choice: "maybe" })).status).toBe(400); - expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/repositories/101/approval-requests`, "u-1", {})).status); + const list = await op("listRepositoryApprovalRequests", "u-1", { accountId: acc, query: { repository_id: [101] } }); + expect(list).toEqual({ status: 200, body: { repository_id: 101, organizer: { current_seq: null, wait_reason: null, wait_detail: null }, items: [] } }); + const history = await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=5`, "u-1"); + expect(await history.json()).toEqual([]); + expect((await call("GET", `/accounts/${acc}/releases?repository_id=101&limit=0`, "u-1")).status).toBe(400); + const resolve = (choice: string) => op("resolveStalledRelease", "u-1", { accountId: acc, items: [{ repository_id: 101, seq: 1, choice }] }); + expect((await resolve("reject")).status).toBe(404); + expect((await resolve("maybe")).status).toBe(400); + expect([404, 405]).toContain((await call("POST", `/accounts/${acc}/approval-requests`, "u-1", [{}])).status); }); it("他人のアカウントのリポジトリは 403", async () => { const acc = await personal("u-1"); await register(acc); await personal("u-2"); - expect((await call("GET", `/accounts/${acc}/repositories/101/approval-requests`, "u-2")).status).toBe(403); + expect((await op("listRepositoryApprovalRequests", "u-2", { accountId: acc, query: { repository_id: [101] } })).status).toBe(403); }); }); diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts index 71b41254f..758a237fd 100644 --- a/factory/api/src/routes/factory.ts +++ b/factory/api/src/routes/factory.ts @@ -1,7 +1,8 @@ // 画面向けの経路(#2186・#2347・#2172)。取り込み・キーの持ち込み・課題の候補・スプリントの実行・開発ループ。 -// どの経路も resolveFromRequest でアカウントを解決し、他人のアカウントは #2170 の I18 と同じ 403 -import type { Context, Hono } from "hono"; -import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; +// どの操作も操作の登録の層(operation.ts)で登録し、経路のアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403。 +// 要素の形は機能から起こした契約(#2384)で、要素ごとの失敗は要素の結果になる +import type { Hono } from "hono"; +import type { AuthEnv } from "../auth/resolve.ts"; import type { CredentialService } from "../credentials/service.ts"; import { InvalidRequestError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; @@ -12,7 +13,8 @@ import { chooseCandidates, type CandidateModel } from "../runs/candidates.ts"; import type { RunControl } from "../runs/control.ts"; import type { LoopControl } from "../runs/loops.ts"; import type { RunsTable } from "../runs/table.ts"; -import { readJson } from "./body.ts"; +import { parseIdempotencyKey } from "../idempotency.ts"; +import { registerList, registerOperation } from "./operation.ts"; import { registerSprintViews, sprintRunList } from "./sprint-views.ts"; export interface FactoryDeps { @@ -28,147 +30,121 @@ export interface FactoryDeps { reader: ProgressReader; } -function positiveInt(raw: string | undefined): number { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); - return n; +export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number { + const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw; + if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message); + return n as number; } -export function registerFactory(app: Hono, deps: () => FactoryDeps): void { - const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId")); +function idOf(raw: unknown, message: string): string { + if (typeof raw !== "string" || raw === "") throw new InvalidRequestError(message); + return raw; +} - app.get("/accounts/:accountId/github/install-url", async (c) => { - await ctxOf(c); - return c.json(deps().repositories.installUrl()); - }); +const removed = { removed: true } as const; - app.get("/accounts/:accountId/github/authorize-url", async (c) => { - return c.json(await deps().repositories.authorizeUrl(await ctxOf(c))); - }); +export function registerFactory(app: Hono, deps: () => FactoryDeps): void { + registerList(app, "getInstallUrl", async () => [deps().repositories.installUrl()]); - app.post("/accounts/:accountId/github/candidates", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.candidates(ctx, await readJson(c))); - }); + registerList(app, "getAuthorizeUrl", async (_c, ctx) => [await deps().repositories.authorizeUrl(ctx!)]); - app.get("/accounts/:accountId/repositories", async (c) => { - return c.json(await deps().repositories.list(await ctxOf(c))); - }); + registerOperation(app, "postRepositoryCandidates", { run: (e, ctx) => deps().repositories.candidates(ctx!, e) }); - app.post("/accounts/:accountId/repositories", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.register(ctx, await readJson(c)), 201); - }); + registerList(app, "listRepositories", async (_c, ctx) => (await deps().repositories.list(ctx!)).repositories); - app.patch("/accounts/:accountId/repositories/:repositoryId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().repositories.declareConcurrency(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c))); - }); + registerOperation(app, "registerRepository", { run: (e, ctx) => deps().repositories.register(ctx!, e) }); - app.delete("/accounts/:accountId/repositories/:repositoryId", async (c) => { - const ctx = await ctxOf(c); - await deps().repositories.unregister(ctx, positiveInt(c.req.param("repositoryId"))); - return c.body(null, 204); + registerOperation(app, "updateRepository", { + run: (e, ctx) => deps().repositories.declareConcurrency(ctx!, positiveInt(e.repository_id), e), }); - app.get("/accounts/:accountId/model-credential", async (c) => { - return c.json(await deps().credentials.get(await ctxOf(c))); + registerOperation(app, "removeRepository", { + run: async (e, ctx) => { + await deps().repositories.unregister(ctx!, positiveInt(e.repository_id)); + return removed; + }, }); - app.put("/accounts/:accountId/model-credential", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().credentials.put(ctx, await readJson(c))); - }); + registerList(app, "getModelCredential", async (_c, ctx) => [await deps().credentials.get(ctx!)]); - app.delete("/accounts/:accountId/model-credential", async (c) => { - await deps().credentials.delete(await ctxOf(c)); - return c.body(null, 204); + registerOperation(app, "putModelCredential", { run: (e, ctx) => deps().credentials.put(ctx!, e) }); + + registerOperation(app, "deleteModelCredential", { + run: async (_e, ctx) => { + await deps().credentials.delete(ctx!); + return removed; + }, }); - app.post("/accounts/:accountId/repositories/:repositoryId/issue-candidates", async (c) => { - const ctx = await ctxOf(c); - const body = await readJson(c); - const exclude = Array.isArray(body.exclude) ? body.exclude.filter((n): n is number => Number.isInteger(n)) : []; - const d = deps(); - const list = await chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx, positiveInt(c.req.param("repositoryId")), exclude); - return c.json(list); + registerOperation(app, "postIssueCandidates", { + run: (e, ctx) => { + const exclude = Array.isArray(e.exclude) ? e.exclude.filter((n): n is number => Number.isInteger(n)) : []; + const d = deps(); + return chooseCandidates({ table: d.table, github: d.github, model: d.candidateModel }, ctx!, positiveInt(e.repository_id), exclude); + }, }); - app.post("/accounts/:accountId/sprint-runs", async (c) => { - const ctx = await ctxOf(c); - const body = await readJson(c); - return c.json(await deps().control.startSprintRun(ctx, { repositoryId: body.repository_id, issues: body.issues }), 201); + // 同じ冪等の鍵の 2 回目は 1 回目の実行の識別子を返す(#2384 の I14) + registerOperation(app, "startSprintRun", { + run: (e, ctx) => + deps().control.startSprintRun(ctx!, { repositoryId: e.repository_id, issues: e.issues, idempotencyKey: parseIdempotencyKey(e.idempotency_key) }), }); - app.get("/accounts/:accountId/sprint-runs", async (c) => { - const ctx = await ctxOf(c); + registerList(app, "listSprintRuns", async (c, ctx) => { const raw = c.req.query("repository_id"); - return c.json({ sprint_runs: await sprintRunList(deps(), ctx, raw === undefined ? null : positiveInt(raw)) }); + return sprintRunList(deps(), ctx!, raw === undefined ? null : positiveInt(raw)); }); // スプリントの詳細・工程の中身・バックログ(#2172) registerSprintViews(app, deps); - app.post("/accounts/:accountId/sprint-runs/:sprintRunId/cancel", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().control.cancelSprintRun(ctx, c.req.param("sprintRunId"))); + registerOperation(app, "cancelSprintRun", { + run: (e, ctx) => deps().control.cancelSprintRun(ctx!, idOf(e.sprint_run_id, "実行の ID の形ではありません")), }); // 開発ループ(openapi/loops.yaml) - app.post("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.startLoop(ctx, positiveInt(c.req.param("repositoryId")), await readJson(c)), 201); - }); + registerOperation(app, "startLoop", { run: (e, ctx) => deps().loops.startLoop(ctx!, positiveInt(e.repository_id), { ...e, idempotency_key: parseIdempotencyKey(e.idempotency_key) }) }); - app.get("/accounts/:accountId/repositories/:repositoryId/loops", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.listLoops(ctx, positiveInt(c.req.param("repositoryId")))); - }); + registerList(app, "listLoops", async (c, ctx) => (await deps().loops.listLoops(ctx!, positiveInt(c.req.query("repository_id")))).loops); - app.get("/accounts/:accountId/loops/:loopId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.getLoopDetail(ctx, c.req.param("loopId"))); - }); + // 無いループは今の 404(loop_not_found)のまま要素の結果になる + registerOperation(app, "getLoop", { run: (e, ctx) => deps().loops.getLoopDetail(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.patch("/accounts/:accountId/loops/:loopId", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.updateBudget(ctx, c.req.param("loopId"), await readJson(c))); + registerOperation(app, "updateLoopBudget", { + run: (e, ctx) => deps().loops.updateBudget(ctx!, idOf(e.loop_id, "ループの ID の形ではありません"), { budget: e.budget }), }); - app.post("/accounts/:accountId/loops/:loopId/cancel", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.cancelLoop(ctx, c.req.param("loopId"))); - }); + registerOperation(app, "cancelLoop", { run: (e, ctx) => deps().loops.cancelLoop(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.post("/accounts/:accountId/loops/:loopId/next-lap", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.requestNextLap(ctx, c.req.param("loopId")), 202); - }); + registerOperation(app, "startNextLap", { run: (e, ctx) => deps().loops.requestNextLap(ctx!, idOf(e.loop_id, "ループの ID の形ではありません")) }); - app.post("/accounts/:accountId/loops/:loopId/answers", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().loops.answer(ctx, c.req.param("loopId"), await readJson(c))); + // 人の判断待ちへの答え。人だけが確定できる(扱い human) + registerOperation(app, "answerLoopPending", { + run: async (e, ctx) => { + const loopId = idOf(e.loop_id, "ループの ID の形ではありません"); + const { pending } = await deps().loops.answer(ctx!, loopId, { issue: e.issue, decision: e.decision, pr: e.pr }); + return { loop_id: loopId, pending }; + }, }); - // Factory のオーガナイザー(openapi/organizer.yaml。#2439)。一覧と履歴は読むだけで、承認を確定する経路は持たない(I66) - app.get("/accounts/:accountId/repositories/:repositoryId/approval-requests", async (c) => { - const ctx = await ctxOf(c); - return c.json(await deps().organizer.list(ctx, positiveInt(c.req.param("repositoryId")))); + // Factory のオーガナイザー(openapi/organizer.yaml。#2439) + registerOperation(app, "listRepositoryApprovalRequests", { + run: async (e, ctx) => { + const repositoryId = positiveInt(e.repository_id); + return { repository_id: repositoryId, ...(await deps().organizer.list(ctx!, repositoryId)) }; + }, }); - app.get("/accounts/:accountId/repositories/:repositoryId/releases", async (c) => { - const ctx = await ctxOf(c); + registerList(app, "listRepositoryReleases", async (c, ctx) => { const raw = c.req.query("limit"); const limit = raw === undefined ? 20 : Number(raw); if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new InvalidRequestError("limit は 1〜100 の整数です"); - return c.json(await deps().organizer.history(ctx, positiveInt(c.req.param("repositoryId")), limit)); + return (await deps().organizer.history(ctx!, positiveInt(c.req.query("repository_id")), limit)).items; }); - // 止まった配布を解く。画面の経路は Cognito の利用者の token だけを受ける(承認ゲートではなく失敗の扱い。決定 11) - app.post("/accounts/:accountId/repositories/:repositoryId/releases/:seq/resolve", async (c) => { - const ctx = await ctxOf(c); - const seq = Number(c.req.param("seq")); - if (!Number.isInteger(seq) || seq < 1) throw new InvalidRequestError("まとめた配布の連番の形ではありません"); - return c.json(await deps().organizer.resolve(ctx, positiveInt(c.req.param("repositoryId")), seq, await readJson(c)), 202); + // 止まった配布を解く。人だけが確定できる(扱い human。オーガナイザーの I66) + registerOperation(app, "resolveStalledRelease", { + run: (e, ctx) => + deps().organizer.resolve(ctx!, positiveInt(e.repository_id), positiveInt(e.seq, "まとめた配布の連番の形ではありません"), { choice: e.choice }), }); } diff --git a/factory/api/src/routes/me.ts b/factory/api/src/routes/me.ts index 4cd5a0a48..5732e8ee3 100644 --- a/factory/api/src/routes/me.ts +++ b/factory/api/src/routes/me.ts @@ -1,16 +1,19 @@ import type { Hono } from "hono"; import type { AuthEnv } from "../auth/resolve.ts"; +import { registerList } from "./operation.ts"; import { accountSummary } from "./views.ts"; export function registerMe(app: Hono): void { - // 初回は個人のアカウントを作る(E2) - app.get("/me", async (c) => { + // 初回は個人のアカウントを作る(E2)。要素 1 個の配列で返す + registerList(app, "getMe", async (c) => { const { user, contexts } = await c.get("auth").accounts.listAccounts(c.get("caller")); - return c.json({ - user_id: user.userId, - idp: user.idp, - personal_account_id: user.personalAccountId, - accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)), - }); + return [ + { + user_id: user.userId, + idp: user.idp, + personal_account_id: user.personalAccountId, + accounts: contexts.map((ctx) => accountSummary(ctx, ctx.name)), + }, + ]; }); } diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts new file mode 100644 index 000000000..bad0a7713 --- /dev/null +++ b/factory/api/src/routes/operation.ts @@ -0,0 +1,126 @@ +// 操作の登録の層(#2384 の決定 5・不変条件 I10・I12・I13)。経路を操作の表から登録し、要素の数と重複の検査・ +// 経路のアカウントの解決・要素ごとの実行・操作の記録へ渡す値を、すべての利用者向けの操作に同じく掛ける +import type { Context, Hono } from "hono"; +import { MAX_ITEMS, operation, type Operation, type OperationId } from "@ndf-factory/contracts/operations"; +import type { AccountContext } from "../accounts/model.ts"; +import { resolveFromRequest, type AuthEnv, type OperationTrace } from "../auth/resolve.ts"; +import { InvalidRequestError } from "../errors.ts"; +import { runElements, type ElementResult } from "./elements.ts"; + +export type Element = Record; + +export interface ElementHandler { + // 要求全体の前に 1 度だけ行う(条件の照合など)。投げると要求全体を断る + prepare?: (c: Context, ctx: AccountContext | null) => Promise; + // 要素 1 つの値を返す。ApiError を投げるとその要素だけが失敗の結果になる + run: (element: Element, ctx: AccountContext | null, shared: S, c: Context) => Promise; +} + +const honoPath = (op: Operation) => op.path.replace("{accountId}", ":accountId"); +const method = (op: Operation) => op.method.toLowerCase() as "get" | "post" | "put" | "patch" | "delete"; + +// 経路にアカウントがあれば今と同じく解決し、権限が無ければ要求全体を 403 にする(決定 2) +async function contextOf(c: Context, op: Operation): Promise { + if (!op.path.includes("{accountId}")) return null; + return resolveFromRequest(c, c.req.param("accountId")); +} + +function trace(c: Context, op: Operation): OperationTrace { + const t: OperationTrace = { operationId: op.operationId, accountId: op.path.includes("{accountId}") ? (c.req.param("accountId") ?? null) : null, targetIds: [], reasons: [] }; + c.set("operation", t); + return t; +} + +function queryValue(op: Operation, key: string, raw: string): unknown { + const schema = op.parameters.find((p) => p.name === key)?.schema as { items?: { type?: string } } | undefined; + return schema?.items?.type === "integer" && /^\d+$/.test(raw) ? Number(raw) : raw; +} + +// 要素を取り出す。鍵の query の繰り返しか、本文の配列。鍵を持たない操作の取得と削除は要素が必ず 1 個 +async function elementsOf(c: Context, op: Operation): Promise { + if (op.kind === "select" || op.kind === "delete") { + if (op.keys.length === 0) return [{}]; + const key = op.keys[0]!; + return (c.req.queries(key) ?? []).map((raw) => ({ [key]: queryValue(op, key, raw) })); + } + let body: unknown; + try { + body = await c.req.json(); + } catch { + throw new InvalidRequestError("本文は要素の配列にしてください"); + } + if (!Array.isArray(body)) throw new InvalidRequestError("本文は要素の配列にしてください"); + return body; +} + +const isElement = (v: unknown): v is Element => typeof v === "object" && v !== null && !Array.isArray(v); + +// 要素の数と重複の検査(I12)。どれかに当たれば、どの要素も処理しない +function checkElements(op: Operation, elements: unknown[]): void { + if (elements.length === 0) throw new InvalidRequestError("要素を 1 個以上送ってください", "empty_items"); + if (elements.length > MAX_ITEMS) { + throw new InvalidRequestError(`1 回に送れる要素は ${MAX_ITEMS} 個までです`, "too_many_items", { limit: MAX_ITEMS }); + } + const seen = new Set(); + for (const e of elements) { + const id = op.keys.length === 0 ? "" : JSON.stringify(op.keys.map((k) => (isElement(e) ? (e[k] ?? null) : null))); + if (seen.has(id)) { + throw new InvalidRequestError("同じ鍵の要素が 2 つあります", "duplicate_key", { key: op.keys.length === 0 ? null : op.keys.join("+") }); + } + seen.add(id); + } +} + +function pick(value: unknown, path: string): unknown { + return path.split(".").reduce((v, k) => (isElement(v) ? v[k] : undefined), value); +} + +// 操作の記録へ渡す対象の ID(要素の鍵と作った識別子)と、失敗した要素の理由 +function record(t: OperationTrace, op: Operation, elements: unknown[], results: ElementResult[]): void { + elements.forEach((e, i) => { + for (const k of op.keys) { + const v = isElement(e) ? e[k] : undefined; + if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${k}=${v}`); + } + const r = results[i]; + if (op.idField && r?.ok) { + const v = pick(r.value, op.idField); + if (typeof v === "string" || typeof v === "number") t.targetIds.push(`${op.idField.split(".").at(-1)}=${v}`); + } + if (r && !r.ok && !t.reasons.includes(r.error.code)) t.reasons.push(r.error.code); + }); +} + +// 一覧(条件で集めた値の配列。要素の結果で包まない) +export function registerList( + app: Hono, + operationId: OperationId, + list: (c: Context, ctx: AccountContext | null) => Promise, +): void { + const op = operation(operationId); + if (op.kind !== "list") throw new Error(`${operationId} は一覧ではありません`); + app[method(op)](honoPath(op), async (c) => { + trace(c, op); + const ctx = await contextOf(c, op); + return c.json(await list(c, ctx)); + }); +} + +// 一覧を除く操作(指定の取得・作成・更新・削除・操作) +export function registerOperation(app: Hono, operationId: OperationId, handler: ElementHandler): void { + const op = operation(operationId); + if (op.kind === "list") throw new Error(`${operationId} は一覧です`); + app[method(op)](honoPath(op), async (c) => { + const t = trace(c, op); + const elements = await elementsOf(c, op); + checkElements(op, elements); + const ctx = await contextOf(c, op); + const shared = (handler.prepare ? await handler.prepare(c, ctx) : undefined) as S; + const results = await runElements(elements, (e) => { + if (!isElement(e)) throw new InvalidRequestError("要素はオブジェクトにしてください"); + return handler.run(e, ctx, shared, c); + }); + record(t, op, elements, results); + return c.json(results); + }); +} diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts index 241ae60a9..c463e8cbb 100644 --- a/factory/api/src/routes/sprint-views.test.ts +++ b/factory/api/src/routes/sprint-views.test.ts @@ -12,7 +12,7 @@ import { LoopControl } from "../runs/loops.ts"; import type { SprintRun } from "../runs/model.ts"; import { createRunsHarness, type RunsHarness } from "../testing/runs.ts"; import { createTokenKit, type TokenKit } from "../testing/tokens.ts"; -import type { TestResponse } from "../testing/app.ts"; +import { firstResult, type TestResponse } from "../testing/app.ts"; import type { FactoryDeps } from "./factory.ts"; const noSecrets: SecretsPort = { create: async () => "arn", put: async () => undefined, delete: async () => undefined }; @@ -27,8 +27,14 @@ async function call(path: string, user = "u-1"): Promise { return (await app.request(path, { headers: { Authorization: await tokens.bearer(user) } })) as TestResponse; } +// 要素 1 個の結果を状態と本文で読む(#2384) +const read = async (path: string, user = "u-1") => firstResult(await call(path, user)); +const detailPath = (acc: string, id: string) => `/accounts/${acc}/sprint-runs/detail?sprint_run_id=${encodeURIComponent(id)}`; +const stepPath = (acc: string, id: string, key: string) => `/accounts/${acc}/sprint-runs/steps?sprint_run_id=${encodeURIComponent(id)}&step_key=${encodeURIComponent(key)}`; +const backlogPath = (acc: string, repositoryId: number) => `/accounts/${acc}/backlogs?repository_id=${repositoryId}`; + async function personal(user: string): Promise { - return ((await (await call("/me", user)).json()) as { personal_account_id: string }).personal_account_id; + return ((await (await call("/me", user)).json())[0] as { personal_account_id: string }).personal_account_id; } function runOf(accountId: string, patch: Partial = {}): SprintRun { @@ -128,7 +134,7 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => { const { acc } = await seed(); const res = await call(`/accounts/${acc}/sprint-runs`); expect(res.status).toBe(200); - const { sprint_runs } = await res.json(); + const sprint_runs = await res.json(); expect(sprint_runs.map((r: { sprint_run_id: string }) => r.sprint_run_id)).toEqual(["sr-2", "sr-1"]); const row = sprint_runs[1]; expect(row).toEqual({ @@ -150,15 +156,15 @@ describe("一覧の経路(受け入れ条件 1〜3・18)", () => { describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)", () => { it("詳細は本文を返さず、工程の中身は 1 件ずつ本文を返す。どちらも GitHub の口を呼ばない", async () => { const { acc, step } = await seed(); - const detail = await call(`/accounts/${acc}/sprint-runs/sr-1`); + const detail = await read(detailPath(acc, "sr-1")); expect(detail.status).toBe(200); - const body = await detail.json(); + const body = detail.body; expect(body).toMatchObject({ sprint_run_id: "sr-1", repo: "org-a/repo-a", current_phase: "実装", ndf_version: "10.27.15", job_count: 1, omitted_steps: 0 }); expect(body.steps[0]).toMatchObject({ key: step, phase: "実装", issues: [11], has_details: true }); expect(JSON.stringify(body)).not.toContain("## 作業の報告"); - const one = await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); + const one = await read(stepPath(acc, "sr-1", step)); expect(one.status).toBe(200); - expect((await one.json()).output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); + expect(one.body.output).toEqual({ kind: "report", text: "## 作業の報告", truncated: false }); await call(`/accounts/${acc}/sprint-runs`); expect(githubCalls).toEqual([]); }); @@ -166,9 +172,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)" it("足す前の形の step.recorded(schema_version 1 のまま)も詳細に出て、本文の欄は null", async () => { const { acc } = await seed(); const key = await emit("sr-1", "step.recorded", { step: "pr", exit_code: 0, seconds: 1, summary: "作った" }); - const body = await (await call(`/accounts/${acc}/sprint-runs/sr-1`)).json(); + const body = (await read(detailPath(acc, "sr-1"))).body; expect(body.steps.map((s: { key: string }) => s.key)).toContain(key); - const one = await (await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(key)}`)).json(); + const one = (await read(stepPath(acc, "sr-1", key))).body; expect([one.files, one.decisions, one.output]).toEqual([null, null, null]); }); @@ -176,9 +182,9 @@ describe("詳細と工程の中身の経路(受け入れ条件 7・13・14)" const { acc, step } = await seed(); const before = JSON.stringify([h.progress.events, [...h.table.runs.values()]]); await call(`/accounts/${acc}/sprint-runs`); - await call(`/accounts/${acc}/sprint-runs/sr-1`); - await call(`/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`); - await call(`/accounts/${acc}/repositories/101/backlog`); + await call(detailPath(acc, "sr-1")); + await call(stepPath(acc, "sr-1", step)); + await call(backlogPath(acc, 101)); expect(JSON.stringify([h.progress.events, [...h.table.runs.values()]])).toBe(before); }); }); @@ -188,28 +194,24 @@ describe("認可(受け入れ条件 11・I12)", () => { const { acc, step } = await seed(); const other = await personal("u-2"); h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other" })); - const paths = [ - `/accounts/${acc}/sprint-runs`, - `/accounts/${acc}/sprint-runs/sr-1`, - `/accounts/${acc}/sprint-runs/sr-1/steps/${encodeURIComponent(step)}`, - `/accounts/${acc}/repositories/101/backlog`, - ]; + const paths = [`/accounts/${acc}/sprint-runs`, detailPath(acc, "sr-1"), stepPath(acc, "sr-1", step), backlogPath(acc, 101)]; const missing = "00000000-0000-4000-8000-0000000000ff"; const cases = [ ...paths.map((p) => [p, "u-2"] as const), ...paths.map((p) => [p.replace(acc, missing), "u-1"] as const), - [`/accounts/${acc}/sprint-runs/sr-other`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-none`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-other/steps/${encodeURIComponent(step)}`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-1/steps/job-1%3A999`, "u-1"], - [`/accounts/${acc}/sprint-runs/sr-2/steps/${encodeURIComponent(step)}`, "u-1"], - [`/accounts/${acc}/repositories/999/backlog`, "u-1"], + [detailPath(acc, "sr-other"), "u-1"], + [detailPath(acc, "sr-none"), "u-1"], + [stepPath(acc, "sr-other", step), "u-1"], + [stepPath(acc, "sr-1", "job-1:999"), "u-1"], + [stepPath(acc, "sr-2", step), "u-1"], + [backlogPath(acc, 999), "u-1"], ] as const; + // 要求全体の 403 と要素の forbidden は、同じ本文を持つ const bodies = new Set(); for (const [path, user] of cases) { - const res = await call(path, user); + const res = await read(path, user); expect(res.status, path).toBe(403); - bodies.add(await res.text()); + bodies.add(JSON.stringify(res.body)); } expect([...bodies]).toEqual([JSON.stringify({ error: { code: "forbidden", message: "このアカウントを見る権限がありません" } })]); }); @@ -221,9 +223,9 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () => h.github.rankings.set("org-a/repo-a", { milestone: "25 見本", rows: [{ number: 5, title: "先に" }, { number: 3, title: "後に" }] }); await emit("sr-1", "step.recorded", { step: "refine", exit_code: 0, seconds: 3, summary: "古い棚卸し" }, "2026-11-02T00:00:00.000Z"); await emit("sr-2", "step.recorded", { step: "refine", exit_code: 1, seconds: 3, summary: "新しい棚卸し" }, "2026-11-03T00:00:00.000Z"); - const res = await call(`/accounts/${acc}/repositories/101/backlog`); + const res = await read(backlogPath(acc, 101)); expect(res.status).toBe(200); - const body = await res.json(); + const body = res.body; expect(body.repo).toBe("org-a/repo-a"); expect(body.ranking).toEqual({ milestone: "25 見本", @@ -238,12 +240,54 @@ describe("バックログの経路(受け入れ条件 15・16・I13)", () => it("順位の表が無ければ ranking は null、GitHub の口が失敗すれば 503", async () => { const { acc } = await seed(); - const none = await (await call(`/accounts/${acc}/repositories/101/backlog`)).json(); + const none = (await read(backlogPath(acc, 101))).body; expect(none.ranking).toBeNull(); expect(none.refinements).toEqual([]); h.github.findRankingTable = async () => { throw new Error("GitHub 502"); }; - expect((await call(`/accounts/${acc}/repositories/101/backlog`)).status).toBe(503); + expect((await read(backlogPath(acc, 101))).status).toBe(503); + }); +}); + +describe("配列の受け渡し(#2384 の受け入れ条件 6〜8・15、I12・I13)", () => { + it("3 個の要素のうち他人の 1 個だけが forbidden で、順と数は入力と同じ。他人のデータは応答に無い", async () => { + const { acc } = await seed(); + const other = await personal("u-2"); + h.table.runs.set("sr-other", runOf(other, { sprintRunId: "sr-other", repo: "org-x/secret-repo" })); + const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-other&sprint_run_id=sr-2`); + expect(res.status).toBe(200); + const results = await res.json(); + expect(results.map((r: { ok: boolean; value?: { sprint_run_id: string }; error?: { code: string } }) => (r.ok ? r.value!.sprint_run_id : r.error!.code))).toEqual([ + "sr-1", + "forbidden", + "sr-2", + ]); + expect(JSON.stringify(results)).not.toContain("secret-repo"); + }); + + it("101 個の要素は、どれも処理せずに 400 と上限 100、0 個は 400", async () => { + const { acc } = await seed(); + const reads: string[] = []; + const getRun = h.table.getRun.bind(h.table); + h.table.getRun = async (id: string) => { + reads.push(id); + return getRun(id); + }; + const many = Array.from({ length: 101 }, (_, i) => `sprint_run_id=sr-${i}`).join("&"); + const res = await call(`/accounts/${acc}/sprint-runs/detail?${many}`); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatchObject({ code: "too_many_items", details: { limit: 100 } }); + const empty = await call(`/accounts/${acc}/sprint-runs/detail`); + expect(empty.status).toBe(400); + expect((await empty.json()).error.code).toBe("empty_items"); + expect(reads).toEqual([]); + }); + + it("同じ鍵の要素が 2 つあれば 400 duplicate_key", async () => { + const { acc } = await seed(); + const res = await call(`/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-1&sprint_run_id=sr-1`); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatchObject({ code: "duplicate_key", details: { key: "sprint_run_id" } }); }); }); diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts index 95407222d..ae8559ea5 100644 --- a/factory/api/src/routes/sprint-views.ts +++ b/factory/api/src/routes/sprint-views.ts @@ -1,39 +1,22 @@ // スプリントの一覧・詳細・工程の中身・バックログの読み出しの経路(#2172)。読むだけで、事象の表・実行の表・GitHub へ書かない(I14)。 -// アカウントは resolveFromRequest で解決し、他人のアカウント・属さない実行・無い実行・無い工程・登録の無いリポジトリは -// どれも同じ本文の 403(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) -import type { Context, Hono } from "hono"; +// アカウントは操作の登録の層が解決する。属さない実行・無い実行・無い工程・登録の無いリポジトリの要素は、 +// どれも他人のアカウントと同じ本文の forbidden の結果(I12)。一覧・詳細・工程の中身は GitHub の口を呼ばず、バックログは順位の表の読み出しだけを呼ぶ(I13) +import type { Hono } from "hono"; import type { AccountContext } from "../accounts/model.ts"; -import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; +import type { AuthEnv } from "../auth/resolve.ts"; import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts"; import type { RankingTable } from "../github/port.ts"; import type { SprintRun } from "../runs/model.ts"; import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts"; -import type { FactoryDeps } from "./factory.ts"; +import { mapLimit } from "./elements.ts"; +import { positiveInt, type FactoryDeps } from "./factory.ts"; +import { registerOperation } from "./operation.ts"; // 一覧の行ごとの読み出しを並べる数の上限 const LIST_CONCURRENCY = 10; // バックログで棚卸しの記録を読む実行の数(新しい順) const BACKLOG_RUNS = 50; -export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const out = new Array(items.length); - let next = 0; - const worker = async () => { - while (next < items.length) { - const i = next++; - out[i] = await fn(items[i]!); - } - }; - await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); - return out; -} - -function positiveInt(raw: string | undefined): number { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); - return n; -} - // 保存の失敗は 503(応答に中身を出さない) async function readOrUnavailable(read: () => Promise): Promise { try { @@ -69,49 +52,57 @@ export async function sprintRunList(deps: FactoryDeps, ctx: AccountContext, repo } export function registerSprintViews(app: Hono, deps: () => FactoryDeps): void { - const ctxOf = (c: Context) => resolveFromRequest(c, c.req.param("accountId")); - - app.get("/accounts/:accountId/sprint-runs/:sprintRunId", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const run = await ownRun(d, ctx, c.req.param("sprintRunId")); - const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); - return c.json(deriveDetail(run, events, omittedSteps)); + registerOperation(app, "getSprintRunDetail", { + run: async (e, ctx) => { + const d = deps(); + const run = await ownRun(d, ctx!, typeof e.sprint_run_id === "string" ? e.sprint_run_id : ""); + const { events, omittedSteps } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); + return deriveDetail(run, events, omittedSteps); + }, }); - app.get("/accounts/:accountId/sprint-runs/:sprintRunId/steps/:stepKey", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const run = await ownRun(d, ctx, c.req.param("sprintRunId")); - const key = c.req.param("stepKey"); - const [record, { events }] = await readOrUnavailable(() => Promise.all([d.reader.getStep(run.sprintRunId, key), d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)])); - if (!record) throw new ForbiddenError(); - // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない) - const row = stepRows(events).find((r) => r.key === key) ?? stepRows([record])[0]!; - return c.json(deriveStepDetail(run, row, record)); + // 1 つの実行の中の工程の鍵を要素にする。実行は条件で、他人の実行なら要求全体が 403 + registerOperation(app, "getStepDetail", { + prepare: async (c, ctx) => { + const ids = c.req.queries("sprint_run_id") ?? []; + if (ids.length !== 1) throw new InvalidRequestError("sprint_run_id を 1 つ指してください"); + const d = deps(); + const run = await ownRun(d, ctx!, ids[0]!); + const { events } = await readOrUnavailable(() => d.reader.listRunEvents(run.sprintRunId, DETAIL_STEP_LIMIT)); + return { run, rows: stepRows(events) }; + }, + run: async (e, _ctx, { run, rows }) => { + const key = typeof e.step_key === "string" ? e.step_key : ""; + const record = await readOrUnavailable(() => deps().reader.getStep(run.sprintRunId, key)); + if (!record) throw new ForbiddenError(); + // 上限の外の古い工程は、その工程だけの列から行を作る(フェーズは payload.phase だけ・費用は結べない) + const row = rows.find((r) => r.key === key) ?? stepRows([record])[0]!; + return deriveStepDetail(run, row, record); + }, }); - app.get("/accounts/:accountId/repositories/:repositoryId/backlog", async (c) => { - const ctx = await ctxOf(c); - const d = deps(); - const repositoryId = positiveInt(c.req.param("repositoryId")); - const registration = await d.table.getRegistration(ctx.accountId, repositoryId); - if (!registration) throw new ForbiddenError(); - const runs = (await d.table.listRuns(ctx.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS); - const [ranking, perRun] = await Promise.all([ - d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => { - throw new ServiceUnavailableError("github_unavailable"); - }), - readOrUnavailable(() => - mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })), - ), - ]); - return c.json({ - repository_id: repositoryId, - repo: registration.fullName, - ranking: rankingOf(ranking), - refinements: deriveRefinements(perRun), - }); + registerOperation(app, "getBacklog", { + run: async (e, ctx) => { + const d = deps(); + const repositoryId = positiveInt(e.repository_id); + const registration = await d.table.getRegistration(ctx!.accountId, repositoryId); + if (!registration) throw new ForbiddenError(); + const runs = (await d.table.listRuns(ctx!.accountId, 1000)).filter((r) => r.repositoryId === repositoryId).slice(0, BACKLOG_RUNS); + const [ranking, perRun] = await Promise.all([ + d.github.findRankingTable(registration.installationId, repositoryId, registration.fullName).catch((): never => { + throw new ServiceUnavailableError("github_unavailable"); + }), + readOrUnavailable(() => + mapLimit(runs, LIST_CONCURRENCY, async (r) => ({ sprintRunId: r.sprintRunId, steps: await d.reader.listStepsNamed(r.sprintRunId, "refine") })), + ), + ]); + return { + repository_id: repositoryId, + repo: registration.fullName, + ranking: rankingOf(ranking), + refinements: deriveRefinements(perRun), + }; + }, }); } diff --git a/factory/api/src/routes/teams.ts b/factory/api/src/routes/teams.ts index 49910e565..77c0d275c 100644 --- a/factory/api/src/routes/teams.ts +++ b/factory/api/src/routes/teams.ts @@ -3,17 +3,22 @@ import { InvalidRequestError } from "../errors.ts"; import { isSeatType } from "../accounts/model.ts"; import { validateTeamName } from "../accounts/service.ts"; import type { AuthEnv } from "../auth/resolve.ts"; -import { readJson } from "./body.ts"; +import { parseIdempotencyKey } from "../idempotency.ts"; +import { registerOperation } from "./operation.ts"; import { accountDetail } from "./views.ts"; export function registerTeams(app: Hono): void { - app.post("/teams", async (c) => { - const body = await readJson(c); - const name = validateTeamName(body.name); - if (!isSeatType(body.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です"); - const accounts = c.get("auth").accounts; - const ctx = await accounts.createTeam(c.get("caller"), name, body.seat_type); - const seats = await accounts.listSeats(ctx); - return c.json(accountDetail(ctx, { name }, seats), 201); + // 同じ冪等の鍵の 2 回目は 1 回目のチームを返す(#2384 の I14) + registerOperation(app, "createTeam", { + run: async (e, _ctx, _s, c) => { + const name = validateTeamName(e.name); + if (!isSeatType(e.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です"); + const key = parseIdempotencyKey(e.idempotency_key); + const accounts = c.get("auth").accounts; + const ctx = await accounts.createTeam(c.get("caller"), name, e.seat_type, key); + const account = await accounts.getAccount(ctx.accountId); + const seats = await accounts.listSeats(ctx); + return accountDetail(ctx, { name: account?.name ?? name }, seats); + }, }); } diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts index edef472e5..c380befe6 100644 --- a/factory/api/src/runs/control.ts +++ b/factory/api/src/runs/control.ts @@ -31,6 +31,7 @@ import { import type { RunnerPlatform } from "./platform.ts"; import type { RunsTable } from "./table.ts"; import { isLegacyVersion } from "./version.ts"; +import { claimOf, type IdempotencyRef } from "../idempotency.ts"; export const DEFAULT_TIMEOUT_SECONDS = 28_800; export const DEFAULT_TOKEN_TTL_SECONDS = 32_400; @@ -124,6 +125,8 @@ export class ResumeRejectedError extends Error { export interface StartInput { repositoryId: unknown; issues: unknown; + // 作成の冪等の鍵(経路が形を確かめて渡す。#2384 の I14)。無ければ鍵の行を書かない + idempotencyKey?: string; } // 手元のマシンの申告(#2189 の登録と心拍。契約の local-runner-declaration) @@ -194,6 +197,11 @@ export class RunControl { ) { throw new InvalidRequestError(`課題は 1〜${MAX_ISSUES} 件の番号で選んでください`, "invalid_issues", { numbers: [] }); } + const idempotency: IdempotencyRef | undefined = + input.idempotencyKey === undefined ? undefined : { userId: ctx.userId, operationId: "startSprintRun", key: input.idempotencyKey }; + // 同じ鍵の 2 回目は 1 回目の結果を返し、何も確かめず何も書かない + const replayed = idempotency ? await table.getIdempotent(idempotency, this.iso()) : null; + if (replayed) return { sprint_run_id: replayed, state: "starting" }; const registration = await table.getRegistration(ctx.accountId, repositoryId as number); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues as number[]); @@ -207,7 +215,14 @@ export class RunControl { milestoneTitles: titles, milestonesOf: check.milestones ?? {}, start: "new", + ...(idempotency ? { idempotency } : {}), }); + if (created === "duplicate") { + // 同じ鍵の作成が同時に走り、先に書いた方が勝った + const won = await table.getIdempotent(idempotency!, this.iso()); + if (!won) throw new ServiceUnavailableError("sprint_run_unavailable"); + return { sprint_run_id: won, state: "starting" }; + } if (created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable"); return { sprint_run_id: created.sprintRunId, state: "starting" }; } @@ -246,8 +261,9 @@ export class RunControl { milestonesOf: Record; start: "new" | "loop"; loop?: { loop: DevLoop; write: (run: SprintRun) => LoopWrite }; + idempotency?: IdempotencyRef; }, - ): Promise { + ): Promise { const { table, github } = this.deps; let modelCredentialSecretArn: string | null = null; if (ctx.executionMode === "byok") { @@ -315,13 +331,14 @@ export class RunControl { milestoneChecks: opts.milestoneTitles, legacy, ...(opts.loop ? { loop: opts.loop.write(run) } : {}), + ...(opts.idempotency ? { idempotency: claimOf(opts.idempotency, run.sprintRunId, now) } : {}), }; const result = await table.createRun(run, job, slots, guards); if (result === "created") { await this.launch({ ...run, legacy }, job); return { ...run, legacy }; } - if (result === "loop_changed") return "loop_changed"; + if (result === "loop_changed" || result === "duplicate") return result; // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62) if (result === "release_running") { throw new ConflictError("parallel_unsupported_version", "このリポジトリでは、並行を許さない NDF の版の実行と並べて動かせません"); @@ -357,7 +374,9 @@ export class RunControl { const registration = await this.deps.table.getRegistration(loop.accountId, loop.repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const ctx: AccountContext = { accountId: loop.accountId, ...loop.context }; - return this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } }); + const created = await this.admit(ctx, registration, { issues: [], milestoneTitles: [], milestonesOf: {}, start: "loop", loop: { loop, write } }); + // 周回の開始は冪等の鍵の行を書かないため duplicate にならない + return created === "duplicate" ? "loop_changed" : created; } // その UTC の暦月に、アカウントの managed の実行が使った token(形態 managed の上限の判定に使う)。 diff --git a/factory/api/src/runs/dynamo-table.ts b/factory/api/src/runs/dynamo-table.ts index 8a96af074..3ecf26241 100644 --- a/factory/api/src/runs/dynamo-table.ts +++ b/factory/api/src/runs/dynamo-table.ts @@ -28,6 +28,7 @@ import type { } from "./model.ts"; import { isTerminalJob, milestoneHash } from "./model.ts"; import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; export const GSI1 = "gsi1"; const ACTIVE_JOBS = "ACTIVE_JOBS"; @@ -256,6 +257,9 @@ export class DynamoRunsTable implements RunsTable { items.push({ Put: { TableName: this.tableName, Item: { pk: accountPk(run.accountId), sk: loopRunSk(run.loopId, run.sprintRunId), sprintRunId: run.sprintRunId } } }); } // 古い版の実行は、まとめた配布が流れている間は始めない(#2439 の I62。オーガナイザーの根を読むだけ) + // 作成の冪等の鍵の行(#2384 の I14)。期限を過ぎた行は上書きする + const idempotencyAt = items.length; + if (guards?.idempotency) items.push(this.idempotencyPut(guards.idempotency)); const organizerAt = items.length; if (guards?.legacy) { items.push({ @@ -273,6 +277,7 @@ export class DynamoRunsTable implements RunsTable { } catch (err) { const failed = failedIndexes(err); if (failed.length === 0 && !isConditionFailure(err)) throw err; + if (guards?.idempotency && failed.includes(idempotencyAt)) return "duplicate"; if (guards?.legacy && failed.includes(organizerAt)) return "release_running"; if (failed.some((i) => i >= loopStart)) return "loop_changed"; if (failed.some((i) => i >= milestoneStart)) return "issue_in_loop"; @@ -732,8 +737,24 @@ export class DynamoRunsTable implements RunsTable { ); } - async createLoop(loop: DevLoop): Promise<"created" | { conflict: string }> { - const items = [ + private idempotencyPut(claim: IdempotencyClaim): Record { + return { + Put: { + TableName: this.tableName, + Item: { pk: idempotencyPk(claim), sk: "IDEMP", createdId: claim.createdId, createdAt: claim.createdAt, ttl: claim.ttl }, + ConditionExpression: "attribute_not_exists(pk) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(claim.createdAt) }, + }, + }; + } + + async getIdempotent(ref: IdempotencyRef, now: string): Promise { + return liveCreatedId(await this.get<{ createdId: string; ttl: number }>(idempotencyPk(ref), "IDEMP"), now); + } + + async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }> { + const items: Record[] = [ ...loop.milestones.map((title) => ({ Put: { TableName: this.tableName, @@ -754,11 +775,15 @@ export class DynamoRunsTable implements RunsTable { }, }, ]; + const idempotencyAt = items.length; + if (idempotency) items.push(this.idempotencyPut(idempotency)); try { await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); return "created"; } catch (err) { - const failed = failedIndexes(err).filter((i) => i < loop.milestones.length); + const all = failedIndexes(err); + if (idempotency && all.includes(idempotencyAt)) return "duplicate"; + const failed = all.filter((i) => i < loop.milestones.length); if (failed.length === 0 && !isConditionFailure(err)) throw err; return { conflict: loop.milestones[failed[0] ?? 0]! }; } diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts index 9c5711f5a..aa470b992 100644 --- a/factory/api/src/runs/loops.ts +++ b/factory/api/src/runs/loops.ts @@ -22,6 +22,7 @@ import { type WaitReason, } from "./model.ts"; import type { RunsTable } from "./table.ts"; +import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts"; export const LOOP_NAME = /^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$/; export const MAX_MILESTONES = 10; @@ -196,6 +197,11 @@ export class LoopControl { throw new InvalidRequestError(`マイルストーンは 1〜${MAX_MILESTONES} 件、各 ${MAX_MILESTONE_LENGTH} 字までで , を含めずに選んでください`); } const budget = parseBudget(body.budget); + // 作成の冪等の鍵(#2384 の I14)。経路は必ず渡す。同じ鍵の 2 回目は 1 回目のループを返す + const idempotency: IdempotencyRef | undefined = + body.idempotency_key === undefined ? undefined : { userId: ctx.userId, operationId: "startLoop", key: parseIdempotencyKey(body.idempotency_key) }; + const replayed = idempotency ? await this.replayLoop(ctx, idempotency) : null; + if (replayed) return replayed; const registration = await table.getRegistration(ctx.accountId, repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const same = (await table.listLoops(ctx.accountId)).find((l) => l.repositoryId === repositoryId && l.name === name && !isTerminalLoop(l.state)); @@ -226,7 +232,12 @@ export class LoopControl { updatedAt: now, lastRunAt: null, }; - const created = await table.createLoop(loop); + const created = await table.createLoop(loop, idempotency && claimOf(idempotency, loop.loopId, now)); + if (created === "duplicate") { + const won = await this.replayLoop(ctx, idempotency!); + if (!won) throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください"); + return won; + } if (created !== "created") { const share = await table.getMilestoneShare(ctx.accountId, repositoryId, created.conflict); const other = share ? await table.getLoop(ctx.accountId, share.loopId) : null; @@ -242,6 +253,14 @@ export class LoopControl { return { loop_id: loop.loopId, state: after?.state ?? loop.state }; } + // 期限の中の同じ鍵が作ったループの今の状態 + private async replayLoop(ctx: AccountContext, ref: IdempotencyRef): Promise<{ loop_id: string; state: DevLoop["state"] } | null> { + const loopId = await this.deps.table.getIdempotent(ref, this.iso()); + if (!loopId) return null; + const loop = await this.deps.table.getLoop(ctx.accountId, loopId); + return { loop_id: loopId, state: loop?.state ?? "between_laps" }; + } + // 周回の開始(決定 2、I44・I52)。between_laps で今の実行が無いループだけを起こす。条件で落ちれば読み直して 3 回まで async startNextLap(accountId: string, loopId: string, byUser: boolean): Promise { const { table, control } = this.deps; diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts index f17bbcb23..32368112c 100644 --- a/factory/api/src/runs/memory-table.ts +++ b/factory/api/src/runs/memory-table.ts @@ -17,6 +17,7 @@ import { type SprintRun, } from "./model.ts"; import type { ComposeRelease, ResumeOptions, RunsTable, RunTransitionExtras } from "./table.ts"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; const regKey = (a: string, r: number) => `${a}#${r}`; const shareKey = (a: string, r: number, k: string | number) => `${a}#${r}#${k}`; @@ -38,6 +39,8 @@ export class MemoryRunsTable implements RunsTable { readonly runnerSlots = new Map(); // # → 古い版の実行を数えに入れた回数(#2439 の I62) readonly legacySeqs = new Map(); + // 作成の冪等の鍵の行(IDEMP#<利用者>#<操作>#<鍵>)。TTL で消さない + readonly idempotency = new Map(); // Factory のオーガナイザー(#2439)。#(#)→ 行 readonly organizers = new Map(); readonly orgRequests = new Map(); @@ -155,6 +158,7 @@ export class MemoryRunsTable implements RunsTable { if (guards.issues.some((n) => this.issueShares.has(shareKey(run.accountId, run.repositoryId, n)))) return "issue_in_use"; if (guards.milestoneChecks.some((t) => this.milestoneShares.has(shareKey(run.accountId, run.repositoryId, t)))) return "issue_in_loop"; if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed"; + if (guards.idempotency && !this.idempotencyFree(guards.idempotency)) return "duplicate"; } this.accountSlots.set(run.accountId, expected.account + 1); this.repositorySlots.set(repoKey, expected.repository + 1); @@ -165,6 +169,7 @@ export class MemoryRunsTable implements RunsTable { } for (const n of guards.issues) this.issueShares.set(shareKey(run.accountId, run.repositoryId, n), run.sprintRunId); if (guards.loop) this.applyLoop(guards.loop); + if (guards.idempotency) this.idempotency.set(idempotencyPk(guards.idempotency), { ...guards.idempotency }); } this.runs.set(run.sprintRunId, structuredClone({ ...run, ...(guards ? { legacy: guards.legacy } : {}) })); this.jobs.set(job.jobId, structuredClone(job)); @@ -312,11 +317,24 @@ export class MemoryRunsTable implements RunsTable { return true; } - async createLoop(loop: DevLoop) { + // 期限の中の同じ鍵の行が無い(DynamoDB の attribute_not_exists(pk) OR ttl < :now と同じ) + private idempotencyFree(claim: IdempotencyClaim): boolean { + const row = this.idempotency.get(idempotencyPk(claim)); + return !row || row.ttl < epochSeconds(claim.createdAt); + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + await this.tick(); + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } + + async createLoop(loop: DevLoop, idempotency?: IdempotencyClaim) { await this.tick(); for (const title of loop.milestones) { if (this.milestoneShares.has(shareKey(loop.accountId, loop.repositoryId, title))) return { conflict: title }; } + if (idempotency && !this.idempotencyFree(idempotency)) return "duplicate" as const; + if (idempotency) this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); for (const title of loop.milestones) this.milestoneShares.set(shareKey(loop.accountId, loop.repositoryId, title), { loopId: loop.loopId, title }); this.loops.set(loop.loopId, structuredClone(loop)); return "created" as const; diff --git a/factory/api/src/runs/model.ts b/factory/api/src/runs/model.ts index 33a79a0b8..551965454 100644 --- a/factory/api/src/runs/model.ts +++ b/factory/api/src/runs/model.ts @@ -1,6 +1,7 @@ // 集約「スプリントの実行」「実行の枠」「開発ループ」と、同じ表に置くリポジトリの登録・持ち込んだキーの行(#2186・#2347 の設計の「データ構造」) import { createHash } from "node:crypto"; import type { AccountContext, ExecutionMode } from "../accounts/model.ts"; +import type { IdempotencyClaim } from "../idempotency.ts"; // awaiting_reset は local の利用上限のリセット時刻を待つ(終わりの状態でない。枠を数え、今のジョブは空。#2347 の決定 7) export const RUN_STATES = ["starting", "running", "awaiting_approval", "awaiting_reset", "done", "stopped", "failed", "cancelled"] as const; @@ -213,10 +214,12 @@ export interface CreateRunGuards { legacy: boolean; // ループの周回の開始。ループの行を条件つきで書く loop?: LoopWrite; + // 作成の冪等の鍵の行(手動の開始。#2384 の I14)。期限の中の同じ鍵の行があれば duplicate + idempotency?: IdempotencyClaim; } // release_running は、古い版の実行の開始で、リポジトリのまとめた配布が流れていた(#2439 の I62) -export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running"; +export type CreateRunResult = "created" | "slots" | "issue_in_use" | "issue_in_loop" | "legacy" | "loop_changed" | "release_running" | "duplicate"; // マイルストーンの持ち分の行の鍵に使う、題の sha256 の先頭 32 字 export const milestoneHash = (title: string) => createHash("sha256").update(title, "utf8").digest("hex").slice(0, 32); diff --git a/factory/api/src/runs/table.ts b/factory/api/src/runs/table.ts index 231906837..bc84c3712 100644 --- a/factory/api/src/runs/table.ts +++ b/factory/api/src/runs/table.ts @@ -15,6 +15,7 @@ import type { Slots, SprintRun, } from "./model.ts"; +import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts"; // 終わりへの移り・承認待ち・リセット待ちに添える書き込み(決定 2。ループは実行と同じトランザクションで書く) export interface RunTransitionExtras { @@ -98,7 +99,10 @@ export interface RunsTable { // ---- 開発ループ(#2347) ---- // ループの行とマイルストーンの持ち分を 1 回で書く。持ち分が既にあれば、重なった題を返して書かない(I45) - createLoop(loop: DevLoop): Promise<"created" | { conflict: string }>; + // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate(#2384 の I14) + createLoop(loop: DevLoop, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate" | { conflict: string }>; + // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う + getIdempotent(ref: IdempotencyRef, now: string): Promise; getLoop(accountId: string, loopId: string): Promise; // アカウントのループの新しい順 listLoops(accountId: string): Promise; diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts index c49b7a038..28f2afeef 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -1,3 +1,4 @@ +import { operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { createApiApp, type LogEntry } from "../handlers/api.ts"; @@ -6,6 +7,30 @@ import { createTokenKit, type TokenKit } from "./tokens.ts"; // テストで本文を読みやすくするため、json() の型を any にした応答 export type TestResponse = Omit & { json(): Promise }; +// 操作の呼び出し(#2384)。経路と query は操作の表から組む。items は本文の要素の配列 +export interface OpOptions { + user?: string; + idp?: "Google" | "GitHub"; + authorization?: string; + accountId?: string; + query?: Record; + items?: unknown; +} + +export function opRequest(operationId: OperationId, opts: OpOptions): { method: string; path: string; body: unknown } { + const op = operation(operationId); + return { method: op.method, path: operationUrl(op, opts.accountId ?? null, opts.query), body: opts.items }; +} + +// 要素 1 個の要求の結果を、今までの 1 件の応答と同じ形(状態と本文)で読む。要求全体を断ったときはその状態と本文 +export async function firstResult(res: TestResponse): Promise<{ status: number; body: any }> { + const data = await res.json(); + if (res.status !== 200 || !Array.isArray(data)) return { status: res.status, body: data }; + const r = data[0]; + if (r && "ok" in r) return r.ok ? { status: 200, body: r.value } : { status: r.status, body: { error: r.error } }; + return { status: 200, body: r }; +} + export interface TestApi { app: ReturnType; store: MemoryAccountStore; @@ -13,6 +38,7 @@ export interface TestApi { tokens: TokenKit; logs: LogEntry[]; call(method: string, path: string, opts?: { user?: string; idp?: "Google" | "GitHub"; body?: unknown; authorization?: string }): Promise; + op(operationId: OperationId, opts?: OpOptions): Promise; } export async function createTestApi(): Promise { @@ -22,7 +48,7 @@ export async function createTestApi(): Promise { const tokens = await createTokenKit(); const logs: LogEntry[] = []; const app = createApiApp(() => ({ verifier: tokens.verifier, accounts: service }), (e) => logs.push(e)); - return { + const api: TestApi = { app, store, service, @@ -39,5 +65,10 @@ export async function createTestApi(): Promise { body: opts.body === undefined ? undefined : JSON.stringify(opts.body), })) as TestResponse; }, + op(operationId, opts = {}) { + const { method, path, body } = opRequest(operationId, opts); + return api.call(method, path, { ...opts, body }); + }, }; + return api; } diff --git a/factory/web/scripts/check-source.ts b/factory/web/scripts/check-source.ts index ce3c25f3d..2e1e2bd59 100644 --- a/factory/web/scripts/check-source.ts +++ b/factory/web/scripts/check-source.ts @@ -1,4 +1,5 @@ -// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないかを見る +// 画面・部品・文脈のコードに表示の文言を直に書いていないか、画面がダミーの実装を import していないか、 +// API への要求の経路を文字列で直に組んでいないか(要求は操作の表から組む。#2384 の受け入れ条件 1)を見る // 対象は src/**/*.{ts,tsx}。*.test.*・src/dummy/・翻訳のファイル(JSON)は見ない。コメントは見ない // 終了コード: 0 無し / 1 見つけた / 2 構文解析できない import { readFileSync, readdirSync } from "node:fs"; @@ -10,13 +11,15 @@ export interface Finding { file: string; line: number; column: number; - rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import"; + rule: "jsx-text" | "jsx-attr" | "jsx-expr" | "non-ascii" | "dummy-import" | "api-path"; text: string; } const LETTER = /\p{L}/u; const NON_ASCII = /[^\x00-\x7f]/; const TEXT_ATTRS = new Set(["aria-label", "title", "placeholder", "alt", "label"]); +// 要求を送る関数の名前(fetch・doFetch・request など) +const REQUEST_CALLEE = /fetch|request/i; interface Node { type: string; @@ -101,6 +104,21 @@ export function checkSource(file: string, code: string): Finding[] { } break; } + case "CallExpression": { + const callee = node.callee as Node; + const name = callee.type === "Identifier" ? (callee.name as string) : callee.type === "MemberExpression" ? ((callee.property as Node).name as string | undefined) : undefined; + if (name && REQUEST_CALLEE.test(name)) { + for (const arg of node.arguments as Node[]) { + // 先頭が / の文字列か、先頭の固定の部分が / のテンプレート + const head = arg.type === "Literal" ? stringOf(arg) : arg.type === "TemplateLiteral" ? ((arg.quasis as Array<{ value: { cooked: string | null } }>)[0]?.value.cooked ?? "") : null; + if (head !== null && head.startsWith("/")) { + report(arg, "api-path", stringOf(arg) ?? head); + reported.add(arg); + } + } + } + break; + } case "ImportDeclaration": case "ImportExpression": case "ExportAllDeclaration": diff --git a/factory/web/src/App.test.tsx b/factory/web/src/App.test.tsx index e6cc432b4..21ae5f77d 100644 --- a/factory/web/src/App.test.tsx +++ b/factory/web/src/App.test.tsx @@ -70,7 +70,10 @@ function show(path: string, routes: Record Response> = {}, a: Auth ); } -const ok = (body: unknown) => () => Response.json(body); +// 一覧は値の配列、ほかの操作は要素の結果の配列で返す(#2384) +const ok = (body: unknown) => () => Response.json([body]); +const done = (value: unknown) => () => Response.json([{ ok: true, value }]); +const failed = (status: number, error: unknown) => () => Response.json([{ ok: false, status, error }]); const where = () => screen.getByTestId("where").textContent; describe("ログインの画面(受け入れ条件 1・2)", () => { @@ -93,7 +96,7 @@ describe("ログインの画面(受け入れ条件 1・2)", () => { it("戻りの後に自分のアカウントの画面へ移り、料金プランが出る", async () => { const stub: Auth = { ...auth, completeLogin: async () => (sessionStorage.setItem("ndf-factory.access_token", "t"), { ok: true }) }; - show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }, stub); + show("/callback?code=c&state=s", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }, stub); expect(await screen.findByText("料金プラン: Free")).toBeTruthy(); expect(where()).toBe("/account"); }); @@ -103,7 +106,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t")); it("2 つの上限と実行の形態と、席を持つチームが出る", async () => { - show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }); + show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }); expect(await screen.findByText("同時に動かせるスプリントの実行")).toBeTruthy(); expect(screen.getByText("1")).toBeTruthy(); expect(screen.getByText("掛けない")).toBeTruthy(); @@ -113,7 +116,7 @@ describe("自分のアカウント(受け入れ条件 3・8)", () => { }); it("ログアウトで token が消え、自分のアカウントを開くとログインの画面へ移る", async () => { - show("/account", { "GET /me": ok(me), "GET /accounts/p-1": ok(personal) }); + show("/account", { "GET /me": ok(me), "GET /accounts?account_id=p-1": done(personal) }); await userEvent.click(await screen.findByRole("button", { name: "ログアウト" })); expect(auth.getToken()).toBeNull(); expect(visited[0]).toContain("https://auth.example.test/logout"); @@ -133,7 +136,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = beforeEach(() => sessionStorage.setItem("ndf-factory.access_token", "t")); it("席を持つ利用者には上限と席の一覧が出て、席の操作と形態の変更は出ない", async () => { - show("/accounts/t-1", { "GET /accounts/t-1": ok(team) }); + show("/accounts/t-1", { "GET /accounts?account_id=t-1": done(team) }); expect(await screen.findByText("125,000,000")).toBeTruthy(); expect(screen.getByText("10")).toBeTruthy(); expect(screen.getAllByRole("listitem")).toHaveLength(2); @@ -144,9 +147,8 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = it("持ち主には操作が出て、API の 422 の文を操作の下に出す", async () => { show("/accounts/t-1", { - "GET /accounts/t-1": ok({ ...team, role: "owner" }), - "POST /accounts/t-1/seats": () => - Response.json({ error: { code: "user_not_found", message: "その利用者はまだログインしていません" } }, { status: 422 }), + "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }), + "POST /accounts/t-1/seats": failed(422, { code: "user_not_found", message: "その利用者はまだログインしていません" }), }); await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "22222222-2222-4222-8222-222222222222"); await userEvent.click(screen.getByRole("button", { name: "席を足す" })); @@ -154,7 +156,7 @@ describe("チームのアカウント(受け入れ条件 8・10・11)", () = }); it("利用者の ID の形でなければ送らない", async () => { - show("/accounts/t-1", { "GET /accounts/t-1": ok({ ...team, role: "owner" }) }); + show("/accounts/t-1", { "GET /accounts?account_id=t-1": done({ ...team, role: "owner" }) }); await userEvent.type(await screen.findByLabelText("足す利用者の ID"), "octo"); await userEvent.click(screen.getByRole("button", { name: "席を足す" })); expect(await screen.findByText("利用者の ID の形ではありません")).toBeTruthy(); @@ -172,9 +174,9 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("登録が 0 件なら導入の案内と「まだリポジトリがありません」を出す", async () => { show("/accounts/p-1/repositories", { - "GET /accounts/p-1": ok(owner), + "GET /accounts?account_id=p-1": done(owner), "GET /accounts/p-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }), - "GET /accounts/p-1/repositories": ok({ repositories: [] }), + "GET /accounts/p-1/repositories": () => Response.json([]), }); expect(await screen.findByText("まだリポジトリがありません")).toBeTruthy(); expect(screen.getByRole("link", { name: "GitHub App をリポジトリへ入れる" }).getAttribute("href")).toContain("/installations/new"); @@ -183,10 +185,10 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("持ち主でない席の人には登録と外すを出さない", async () => { show("/accounts/t-1/repositories", { - "GET /accounts/t-1": ok(team), + "GET /accounts?account_id=t-1": done(team), "GET /accounts/t-1/github/install-url": ok({ url: "https://github.com/apps/x/installations/new" }), - "GET /accounts/t-1/repositories": ok({ repositories: [{ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }] }), - "GET /accounts/t-1/sprint-runs": ok({ sprint_runs: [{ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }] }), + "GET /accounts/t-1/repositories": ok({ repository_id: 1, full_name: "o/r", installation_id: 2, registered_at: "t", active_sprint_runs: 0 }), + "GET /accounts/t-1/sprint-runs": ok({ sprint_run_id: "s", repository_id: 1, issues: [3], state: "failed", failure_reason: "timeout", created_at: "t" }), }); expect(await screen.findByText("o/r")).toBeTruthy(); expect(screen.getByText("#3: 失敗(timeout)")).toBeTruthy(); @@ -198,7 +200,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める let saved: unknown = null; show("/accounts/p-1/model-credential", { "GET /accounts/p-1/model-credential": ok({ registered: false, kind: null, updated_at: null, external_id: "ndf-factory:p-1" }), - "PUT /accounts/p-1/model-credential": () => ((saved = true), Response.json({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })), + "PUT /accounts/p-1/model-credential": () => ((saved = true), done({ registered: true, kind: "anthropic_api_key", updated_at: "2026-10-31", external_id: "ndf-factory:p-1" })()), }); await userEvent.click(await screen.findByRole("button", { name: "保存する" })); expect(screen.getByRole("alert").textContent).toBe("API キーを入力してください"); @@ -211,7 +213,7 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める it("AI の候補を理由つきで出し、上限の 409 を本数で知らせる", async () => { show("/accounts/p-1/repositories/1/sprints/new", { - "POST /accounts/p-1/repositories/1/issue-candidates": ok({ + "POST /accounts/p-1/issue-candidates": done({ source: "ranking", milestone: "03 x", note: null, @@ -220,11 +222,11 @@ describe("リポジトリ・キーの持ち込み・スプリントを始める { number: 6, title: "六", selected: false, reason: "後でよい" }, ], }), - "POST /accounts/p-1/sprint-runs": () => - Response.json( - { error: { code: "limits_exceeded", message: "x", details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] } } }, - { status: 409 }, - ), + "POST /accounts/p-1/sprint-runs": failed(409, { + code: "limits_exceeded", + message: "x", + details: { reasons: [{ quantity: "concurrent_sprint_runs", limit: 1, current: 1 }] }, + }), }); expect(await screen.findByText("順位が高い")).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: "開始する" })); diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts new file mode 100644 index 000000000..5552287d9 --- /dev/null +++ b/factory/web/src/api.test.ts @@ -0,0 +1,108 @@ +// 画面の API クライアントと契約の対応(#2384 の受け入れ条件 1、性能の条件) +import { describe, expect, it } from "vitest"; +import { MAX_ITEMS, OPERATIONS } from "@ndf-factory/contracts/operations"; +import { createApi, type Api } from "./api.ts"; +import type { Auth } from "./auth.ts"; + +const API_URL = "https://api.example.test"; +const auth = { getToken: () => "t", clearToken: () => {} } as unknown as Auth; + +interface Sent { + method: string; + path: string; + query: URLSearchParams; + body: unknown; +} + +// 送った要求を記録し、要素の数だけ成功の結果を返す偽の API +function recorder() { + const sent: Sent[] = []; + const doFetch: typeof fetch = async (input, init) => { + const url = new URL(String(input)); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + sent.push({ method: init?.method ?? "GET", path: url.pathname, query: url.searchParams, body }); + const op = OPERATIONS.find((o) => o.method === (init?.method ?? "GET") && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(url.pathname)); + if (!op || op.kind === "list") return Response.json([{}]); + const n = Array.isArray(body) ? body.length : op.keys.length === 0 ? 1 : url.searchParams.getAll(op.keys[0]!).length; + return Response.json(Array.from({ length: n }, () => ({ ok: true, value: {} }))); + }; + return { sent, api: createApi(API_URL, auth, doFetch) }; +} + +// Api のメソッドをすべて 1 回ずつ呼ぶ。メソッドを足すとここが型で落ちる(呼び忘れを防ぐ) +const CALLS: { [K in keyof Api]: (api: Api) => Promise } = { + me: (a) => a.me(), + account: (a) => a.account("p-1"), + createTeam: (a) => a.createTeam("t", "standard"), + setExecutionMode: (a) => a.setExecutionMode("p-1", "byok"), + addSeat: (a) => a.addSeat("t-1", "u", "standard"), + changeSeat: (a) => a.changeSeat("t-1", "u", "premium"), + removeSeat: (a) => a.removeSeat("t-1", "u"), + installUrl: (a) => a.installUrl("p-1"), + authorizeUrl: (a) => a.authorizeUrl("p-1"), + repositoryCandidates: (a) => a.repositoryCandidates("p-1", "c", "s"), + repositories: (a) => a.repositories("p-1"), + registerRepository: (a) => a.registerRepository("p-1", "proof", 1), + removeRepository: (a) => a.removeRepository("p-1", 1), + modelCredential: (a) => a.modelCredential("p-1"), + saveModelCredential: (a) => a.saveModelCredential("p-1", { kind: "anthropic_api_key", api_key: "k" }), + deleteModelCredential: (a) => a.deleteModelCredential("p-1"), + issueCandidates: (a) => a.issueCandidates("p-1", 1, []), + startSprintRun: (a) => a.startSprintRun("p-1", 1, [3]), + sprintRuns: (a) => a.sprintRuns("p-1"), + sprintRunDetails: (a) => a.sprintRunDetails("p-1", ["s1"]), + stepDetails: (a) => a.stepDetails("p-1", "s1", ["k1"]), + backlogs: (a) => a.backlogs("p-1", [1]), + cancelSprintRun: (a) => a.cancelSprintRun("p-1", "s1"), + updateRepository: (a) => a.updateRepository("p-1", 1, null), + loops: (a) => a.loops("p-1", 1), + startLoop: (a) => a.startLoop("p-1", 1, { name: "l", milestones: ["m"] }), + loopDetails: (a) => a.loopDetails("p-1", ["l1"]), + updateLoopBudget: (a) => a.updateLoopBudget("p-1", "l1", {}), + cancelLoop: (a) => a.cancelLoop("p-1", "l1"), + requestNextLap: (a) => a.requestNextLap("p-1", "l1"), + answerLoop: (a) => a.answerLoop("p-1", "l1", 3, "approved"), +}; + +describe("画面の API クライアントと契約(受け入れ条件 1)", () => { + it("どのメソッドの要求も契約の利用者向けの操作に当たり、本文は配列である", async () => { + const { sent, api } = recorder(); + for (const [name, call] of Object.entries(CALLS)) { + const before = sent.length; + await call(api); + expect(sent.length, name).toBeGreaterThan(before); + } + for (const s of sent) { + const op = OPERATIONS.find((o) => o.method === s.method && new RegExp(`^${o.path.replace("{accountId}", "[^/]+")}$`).test(s.path)); + expect(op, `${s.method} ${s.path}`).toBeDefined(); + if (s.body !== undefined) expect(Array.isArray(s.body), `${s.method} ${s.path}`).toBe(true); + } + }); + + it("新しい識別子を作る作成には冪等の鍵を付け、呼ぶたびに変える", async () => { + const { sent, api } = recorder(); + await api.startSprintRun("p-1", 1, [3]); + await api.startSprintRun("p-1", 1, [3]); + const keys = sent.map((s) => (s.body as { idempotency_key: string }[])[0]!.idempotency_key); + expect(keys[0]).toMatch(/^[0-9A-Za-z-]{1,64}$/); + expect(keys[0]).not.toBe(keys[1]); + }); +}); + +describe("要求の回数(非機能の条件「性能・拡張性」)", () => { + it("詳細 3 件は 1 回の要求で読み、上限を超える数は 100 ずつに分ける", async () => { + const { sent, api } = recorder(); + expect(await api.sprintRunDetails("p-1", ["a", "b", "c"])).toHaveLength(3); + expect(sent).toHaveLength(1); + expect(sent[0]!.query.getAll("sprint_run_id")).toEqual(["a", "b", "c"]); + sent.length = 0; + await api.loopDetails("p-1", Array.from({ length: MAX_ITEMS + 1 }, (_, i) => `l${i}`)); + expect(sent.map((s) => s.query.getAll("loop_id").length)).toEqual([MAX_ITEMS, 1]); + }); + + it("0 件の取得は要求を送らない", async () => { + const { sent, api } = recorder(); + expect(await api.backlogs("p-1", [])).toEqual([]); + expect(sent).toHaveLength(0); + }); +}); diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index bed32de43..a5a9c7927 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -1,5 +1,6 @@ import type { Auth } from "./auth.ts"; import type { StepDetailBody } from "@ndf-factory/contracts/views"; +import { MAX_ITEMS, operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; // API の応答の形(factory/contracts/openapi/accounts.yaml) export type Plan = "free" | "pro" | "max" | "team"; @@ -270,20 +271,39 @@ export interface Api { answerLoop(id: string, loopId: string, issue: number, decision: LoopDecision, pr?: number): Promise<{ pending: LoopPending[] }>; } +// 要素の結果(#2384)。入力と同じ順・同じ数で返る +type ElementResult = { ok: true; value: T } | { ok: false; status: number; error: { code: string; message: string; details?: Record } }; + +function valueOf(r: ElementResult | undefined): T { + if (!r) throw new ApiFailure(503, "error", ""); + if (r.ok) return r.value; + throw new ApiFailure(r.status, r.error.code, r.error.message, r.error.details ?? null); +} + +// 1 回の要求で受け渡す要素の上限ごとに分ける +function chunks(items: T[]): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += MAX_ITEMS) out.push(items.slice(i, i + MAX_ITEMS)); + return out; +} + +const newKey = () => crypto.randomUUID(); + +// 要求は操作の表から組み、経路の文字列を直に書かない(#2384 の受け入れ条件 1)。 // 401 では token を消す。画面は ApiFailure の status を見てログインへ移る export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (...a) => fetch(...a)): Api { - async function request(method: string, path: string, body?: unknown): Promise { + async function request(id: OperationId, accountId: string | null, opts: { query?: Record; items?: unknown[] } = {}): Promise { const token = auth.getToken(); if (!token) throw new ApiFailure(401, "unauthorized", "unauthorized"); - const res = await doFetch(`${apiUrl}${path}`, { - method, + const op = operation(id); + const res = await doFetch(`${apiUrl}${operationUrl(op, accountId, opts.query)}`, { + method: op.method, headers: { Authorization: `Bearer ${token}`, - ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...(opts.items === undefined ? {} : { "Content-Type": "application/json" }), }, - body: body === undefined ? undefined : JSON.stringify(body), + body: opts.items === undefined ? undefined : JSON.stringify(opts.items), }); - if (res.status === 204) return undefined as T; const data = (await res.json().catch(() => null)) as { error?: { code: string; message: string; details?: Record } } | null; if (!res.ok) { if (res.status === 401) auth.clearToken(); @@ -292,54 +312,65 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. return data as T; } - const seg = encodeURIComponent; + // 一覧(条件で集めた値の配列) + const list = (id: OperationId, accountId: string | null, query?: Record) => request(id, accountId, { query }); + // 要素 1 個を送り、その結果の値を返す。失敗の要素は ApiFailure にする + const one = async (id: OperationId, accountId: string | null, item: Record) => + valueOf((await request[]>(id, accountId, { items: [item] }))[0]); + // 鍵の query で指す取得と削除。要素の結果を入力と同じ順に返す + const select = async (id: OperationId, accountId: string | null, key: string, values: (string | number)[], query: Record = {}) => { + const out: ElementResult[] = []; + for (const part of chunks(values)) out.push(...(await request[]>(id, accountId, { query: { ...query, [key]: part } }))); + return out; + }; + // 鍵の query で指す要素の値の配列。最初の失敗の要素で投げる + const values = async (id: OperationId, accountId: string | null, key: string, ids: (string | number)[], query?: Record) => + (await select(id, accountId, key, ids, query)).map((r) => valueOf(r)); + return { - me: () => request("GET", "/me"), - account: (id) => request("GET", `/accounts/${seg(id)}`), - createTeam: (name, seat_type) => request("POST", "/teams", { name, seat_type }), - setExecutionMode: (id, execution_mode) => request("PUT", `/accounts/${seg(id)}/execution-mode`, { execution_mode }), - addSeat: (id, user_id, seat_type) => request("POST", `/accounts/${seg(id)}/seats`, { user_id, seat_type }), - changeSeat: (id, userId, seat_type) => request("PATCH", `/accounts/${seg(id)}/seats/${seg(userId)}`, { seat_type }), - removeSeat: (id, userId) => request("DELETE", `/accounts/${seg(id)}/seats/${seg(userId)}`), - installUrl: (id) => request("GET", `/accounts/${seg(id)}/github/install-url`), - authorizeUrl: (id) => request("GET", `/accounts/${seg(id)}/github/authorize-url`), - repositoryCandidates: (id, code, state) => request("POST", `/accounts/${seg(id)}/github/candidates`, { code, state }), - repositories: (id) => request("GET", `/accounts/${seg(id)}/repositories`), - registerRepository: (id, proof, repository_id) => request("POST", `/accounts/${seg(id)}/repositories`, { proof, repository_id }), - removeRepository: (id, repositoryId) => request("DELETE", `/accounts/${seg(id)}/repositories/${repositoryId}`), - modelCredential: (id) => request("GET", `/accounts/${seg(id)}/model-credential`), - saveModelCredential: (id, input) => request("PUT", `/accounts/${seg(id)}/model-credential`, input), - deleteModelCredential: (id) => request("DELETE", `/accounts/${seg(id)}/model-credential`), - issueCandidates: (id, repositoryId, exclude) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/issue-candidates`, { exclude }), - startSprintRun: (id, repository_id, issues) => request("POST", `/accounts/${seg(id)}/sprint-runs`, { repository_id, issues }), - sprintRuns: (id, repositoryId) => - request("GET", `/accounts/${seg(id)}/sprint-runs${repositoryId === undefined ? "" : `?repository_id=${repositoryId}`}`), - // 経路は 1 件ずつ読む - sprintRunDetails: (id, sprintRunIds) => - Promise.all(sprintRunIds.map((sprintRunId) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}`))), - stepDetails: (id, sprintRunId, stepKeys) => - Promise.all(stepKeys.map((key) => request("GET", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/steps/${seg(key)}`))), - backlogs: (id, repositoryIds) => Promise.all(repositoryIds.map((repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/backlog`))), - cancelSprintRun: (id, sprintRunId) => request("POST", `/accounts/${seg(id)}/sprint-runs/${seg(sprintRunId)}/cancel`), - updateRepository: (id, repositoryId, max_concurrent_runs) => request("PATCH", `/accounts/${seg(id)}/repositories/${repositoryId}`, { max_concurrent_runs }), - loops: (id, repositoryId) => request("GET", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`), - startLoop: (id, repositoryId, input) => request("POST", `/accounts/${seg(id)}/repositories/${repositoryId}/loops`, input), - // 経路は 1 件ずつ読む。404 の ID は要素を返さない + me: async () => (await list("getMe", null))[0]!, + account: async (id) => (await values("getAccount", null, "account_id", [id]))[0]!, + createTeam: (name, seat_type) => one("createTeam", null, { name, seat_type, idempotency_key: newKey() }), + setExecutionMode: (id, execution_mode) => one("setExecutionMode", null, { account_id: id, execution_mode }), + addSeat: (id, user_id, seat_type) => one("addSeat", id, { user_id, seat_type }), + changeSeat: (id, user_id, seat_type) => one("changeSeat", id, { user_id, seat_type }), + removeSeat: async (id, userId) => { + await values("removeSeat", id, "user_id", [userId]); + }, + installUrl: async (id) => (await list<{ url: string }>("getInstallUrl", id))[0]!, + authorizeUrl: async (id) => (await list<{ url: string }>("getAuthorizeUrl", id))[0]!, + repositoryCandidates: (id, code, state) => one("postRepositoryCandidates", id, { code, state }), + repositories: async (id) => ({ repositories: await list("listRepositories", id) }), + registerRepository: (id, proof, repository_id) => one("registerRepository", id, { proof, repository_id }), + removeRepository: async (id, repositoryId) => { + await values("removeRepository", id, "repository_id", [repositoryId]); + }, + modelCredential: async (id) => (await list("getModelCredential", id))[0]!, + saveModelCredential: (id, input) => one("putModelCredential", id, input), + deleteModelCredential: async (id) => { + valueOf((await request[]>("deleteModelCredential", id))[0]); + }, + issueCandidates: (id, repository_id, exclude) => one("postIssueCandidates", id, { repository_id, exclude }), + startSprintRun: (id, repository_id, issues) => one("startSprintRun", id, { repository_id, issues, idempotency_key: newKey() }), + sprintRuns: async (id, repositoryId) => ({ + sprint_runs: await list("listSprintRuns", id, repositoryId === undefined ? {} : { repository_id: repositoryId }), + }), + // 要素の数によらず 1 回の要求で読む。無い実行は他人の実行と同じ forbidden の要素で、そのまま投げる + sprintRunDetails: (id, sprintRunIds) => values("getSprintRunDetail", id, "sprint_run_id", sprintRunIds), + stepDetails: (id, sprintRunId, stepKeys) => values("getStepDetail", id, "step_key", stepKeys, { sprint_run_id: sprintRunId }), + backlogs: (id, repositoryIds) => values("getBacklog", id, "repository_id", repositoryIds), + cancelSprintRun: (id, sprint_run_id) => one("cancelSprintRun", id, { sprint_run_id }), + updateRepository: (id, repository_id, max_concurrent_runs) => one("updateRepository", id, { repository_id, max_concurrent_runs }), + loops: async (id, repositoryId) => ({ loops: await list("listLoops", id, { repository_id: repositoryId }) }), + startLoop: (id, repository_id, input) => one("startLoop", id, { repository_id, ...input, idempotency_key: newKey() }), + // 要素の数によらず 1 回の要求で読む。無いループ(loop_not_found)の要素は返さない loopDetails: async (id, loopIds) => { - const found = await Promise.all( - loopIds.map((loopId) => - request("GET", `/accounts/${seg(id)}/loops/${seg(loopId)}`).catch((err: unknown) => { - if (err instanceof ApiFailure && err.status === 404) return null; - throw err; - }), - ), - ); - return found.filter((d): d is LoopDetail => d !== null); + const results = await select("getLoop", id, "loop_id", loopIds); + return results.filter((r) => r.ok || r.status !== 404).map((r) => valueOf(r)); }, - updateLoopBudget: (id, loopId, budget) => request("PATCH", `/accounts/${seg(id)}/loops/${seg(loopId)}`, { budget }), - cancelLoop: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/cancel`), - requestNextLap: (id, loopId) => request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/next-lap`), - answerLoop: (id, loopId, issue, decision, pr) => - request("POST", `/accounts/${seg(id)}/loops/${seg(loopId)}/answers`, { issue, decision, ...(pr === undefined ? {} : { pr }) }), + updateLoopBudget: (id, loop_id, budget) => one("updateLoopBudget", id, { loop_id, budget }), + cancelLoop: (id, loop_id) => one("cancelLoop", id, { loop_id }), + requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }), + answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }), }; } diff --git a/factory/web/src/checks.test.ts b/factory/web/src/checks.test.ts index 2ab3f86f1..869f57a28 100644 --- a/factory/web/src/checks.test.ts +++ b/factory/web/src/checks.test.ts @@ -46,6 +46,15 @@ describe("ダミーの import の検査(受け入れ条件 17)", () => { }); }); +describe("API への要求の経路の検査(#2384 の受け入れ条件 1)", () => { + it("要求の関数へ経路の文字列を直に渡すと落とし、操作の表から組んだ経路は通す", () => { + expect(rules("api.ts", `request("GET", "/me");`)).toEqual(["api-path"]); + expect(rules("api.ts", "doFetch(`/accounts/${id}/sprint-runs`);")).toEqual(["api-path"]); + expect(rules("api.ts", "doFetch(`${apiUrl}${operationUrl(op, id)}`);")).toEqual([]); + expect(rules("pages/A.tsx", `navigate("/accounts/a/sprints");`)).toEqual([]); + }); +}); + describe("今の源", () => { it("検査で何も見つからない", () => { expect(checkTree(fileURLToPath(new URL(".", import.meta.url)))).toEqual([]); From 5fce25dcd92954f84c1a587423edc939379bee3e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:40:07 +0000 Subject: [PATCH 15/39] =?UTF-8?q?=E5=A7=94=E4=BB=BB=E3=81=AE=E8=B3=87?= =?UTF-8?q?=E6=A0=BC=E6=83=85=E5=A0=B1=E3=82=92=E7=99=BA=E8=A1=8C=E3=83=BB?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=83=BB=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97?= =?UTF-8?q?=E3=80=81=E4=BA=BA=E3=81=A0=E3=81=91=E3=81=8C=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E6=93=8D=E4=BD=9C=E3=82=92=E5=A7=94?= =?UTF-8?q?=E4=BB=BB=E3=81=8B=E3=82=89=E6=96=AD=E3=82=8B=EF=BC=88#2384?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 委任の資格情報(ndfd__<秘密>)を人が画面で発行し、秘密は SHA-256 だけを保存する。 要求のたびに表を強い整合で読み、期限切れと取り消し済みは 401 にする。操作の登録の層が、 扱い human の操作を範囲によらず 403 human_only、読み取りだけの範囲の書き込みを 403 scope_insufficient で断る。委任の画面(/delegations)を足し、画面の地図に行を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-factory-screen-map.md | 2 + factory/api/src/accounts/model.ts | 6 + factory/api/src/auth/resolve.ts | 32 ++- .../api/src/delegations/delegations.test.ts | 192 ++++++++++++++++++ factory/api/src/delegations/memory-store.ts | 45 ++++ factory/api/src/delegations/model.ts | 47 +++++ factory/api/src/delegations/service.ts | 148 ++++++++++++++ factory/api/src/delegations/store.ts | 129 ++++++++++++ factory/api/src/errors.ts | 14 ++ factory/api/src/handlers/api.ts | 6 + factory/api/src/routes/delegations.ts | 19 ++ factory/api/src/routes/operation.ts | 11 +- factory/api/src/testing/app.ts | 66 +++++- factory/web/src/App.tsx | 2 + factory/web/src/api.test.ts | 3 + factory/web/src/api.ts | 27 +++ factory/web/src/context.tsx | 2 + factory/web/src/dummy/api.ts | 25 ++- factory/web/src/dummy/data.ts | 15 +- factory/web/src/i18n/ja.json | 30 ++- factory/web/src/main.tsx | 2 +- factory/web/src/pages/AccountPage.tsx | 3 + factory/web/src/pages/DelegationsPage.tsx | 161 +++++++++++++++ factory/web/src/pages/delegations.test.tsx | 54 +++++ factory/web/src/screens.ts | 5 +- 25 files changed, 1037 insertions(+), 9 deletions(-) create mode 100644 factory/api/src/delegations/delegations.test.ts create mode 100644 factory/api/src/delegations/memory-store.ts create mode 100644 factory/api/src/delegations/model.ts create mode 100644 factory/api/src/delegations/service.ts create mode 100644 factory/api/src/delegations/store.ts create mode 100644 factory/api/src/routes/delegations.ts create mode 100644 factory/web/src/pages/DelegationsPage.tsx create mode 100644 factory/web/src/pages/delegations.test.tsx diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 67817d9cc..0a26ac8ae 100644 --- a/docs/specifications/ndf-factory-screen-map.md +++ b/docs/specifications/ndf-factory-screen-map.md @@ -40,6 +40,7 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | `/accounts/:accountId/repositories/:repositoryId/loops` | ループ | 個人・管理者・席 | あり | 開発ループの一覧(名前・選定の対象・状態・周回の数・費用・予算の残り・最後に動いた時刻・止まった理由)・始める | | `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める | | `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) | +| `/delegations` | エージェントへの委任 | 個人・管理者・席 | あり | 委任の資格情報の発行(値は発行の直後に 1 度だけ出す)・一覧(範囲・期限・状態)・取り消し。オフラインの間は発行と取り消しをしない。自分のアカウントからたどる | | 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | | 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | | 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | @@ -53,6 +54,7 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | 役割 | 主な動線 | | --- | --- | | 個人 | ログイン → 自分のアカウント → リポジトリ → スプリントを始める → スプリントの一覧 → スプリントの詳細 | +| エージェントに任せる人(個人・管理者・席) | 自分のアカウント → エージェントへの委任(発行して値を MCP のクライアントへ写す・取り消す) | | 開発ループを回す人(個人・管理者・席) | リポジトリ → ループ(始める)→ ループの詳細(人の判断待ちに答える・次の周回を始める)→ スプリントの詳細 | | Team の管理者 | ログイン → 自分のアカウント → チームのアカウント(席の管理・実行の形態)→ キーの持ち込み・リポジトリ | | 席 | ログイン → 自分のアカウント → チームのアカウント → リポジトリ → スプリントを始める → スプリントの一覧 | diff --git a/factory/api/src/accounts/model.ts b/factory/api/src/accounts/model.ts index b8db8392d..36ad453ad 100644 --- a/factory/api/src/accounts/model.ts +++ b/factory/api/src/accounts/model.ts @@ -17,9 +17,15 @@ export const PERSONAL_PLANS: readonly Plan[] = ["free", "pro", "max"]; // 新しいアカウントの実行の形態(I24) export const INITIAL_EXECUTION_MODE: ExecutionMode = "local"; +// 呼び出し元の種類(#2384)。Cognito の token は screen、委任の資格情報の直の要求は api、MCP の受け口の内部の要求は mcp +export type Via = "screen" | "api" | "mcp"; + export interface Caller { userId: string; idp: Idp; + // 委任の資格情報で呼んだときだけ。認可は持たず、人の直の操作と同じ解決を通す(I6) + delegation?: { credentialId: string; scope: "read" | "read_write" } | null; + via?: Via; } export interface UserRecord { diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts index 297f218c1..f374eda8a 100644 --- a/factory/api/src/auth/resolve.ts +++ b/factory/api/src/auth/resolve.ts @@ -1,11 +1,41 @@ import type { Context, MiddlewareHandler } from "hono"; import type { AccountService } from "../accounts/service.ts"; import type { AccountContext, Caller } from "../accounts/model.ts"; +import type { DelegationService } from "../delegations/service.ts"; +import { AuthError } from "../errors.ts"; import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts"; export interface AuthDeps { verifier: AccessTokenVerifier; accounts: AccountService; + // 委任の資格情報の照合(#2384)。無ければ委任の資格情報は 401 + delegations?: DelegationService | null; +} + +// MCP の受け口が内部の要求に付ける印(app.request の第 3 引数の環境に置く)。外から届くヘッダーでは決めない(決定 13・I16) +export const MCP_INTERNAL = Symbol("ndf-factory.mcp-internal"); + +export function isMcpInternal(env: unknown): boolean { + return typeof env === "object" && env !== null && (env as Record)[MCP_INTERNAL] === true; +} + +const DELEGATION_PREFIX = "Bearer ndfd_"; + +// Authorization の値を、委任の資格情報(頭が ndfd_)か Cognito の access token として検証する(E2)。どの失敗も 401 +export async function verifyCaller(deps: AuthDeps, authorization: string | undefined, mcp: boolean): Promise { + if (authorization?.startsWith(DELEGATION_PREFIX)) { + const verified = deps.delegations ? await deps.delegations.verify(authorization.slice("Bearer ".length)) : null; + if (!verified) throw new AuthError(); + return { + userId: verified.userId, + idp: verified.idp, + delegation: { credentialId: verified.credentialId, scope: verified.scope }, + via: mcp ? "mcp" : "api", + }; + } + // MCP の受け口は委任の資格情報だけを受ける + if (mcp) throw new AuthError(); + return { ...(await verifyAuthorization(deps.verifier, authorization)), delegation: null, via: "screen" }; } // 操作の登録の層が、操作の記録へ渡すために置く値(#2384)。対象の ID は許可した鍵の値だけ @@ -25,7 +55,7 @@ export function requireCaller(deps: () => AuthDeps): MiddlewareHandler return async (c, next) => { const d = deps(); c.set("auth", d); - c.set("caller", await verifyAuthorization(d.verifier, c.req.header("Authorization"))); + c.set("caller", await verifyCaller(d, c.req.header("Authorization"), isMcpInternal(c.env))); await next(); }; } diff --git a/factory/api/src/delegations/delegations.test.ts b/factory/api/src/delegations/delegations.test.ts new file mode 100644 index 000000000..6972288f8 --- /dev/null +++ b/factory/api/src/delegations/delegations.test.ts @@ -0,0 +1,192 @@ +// 委任の資格情報と、人だけが確定できる操作(#2384 の受け入れ条件 10〜14・17・18・26、不変条件 I1〜I6・I14) +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { OPERATIONS, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; +import { createTestApi, firstResult, type OpOptions, type TestApi } from "../testing/app.ts"; + +let api: TestApi; +let keys = 0; +beforeEach(async () => { + api = await createTestApi({ factory: true }); +}); + +async function personal(user: string): Promise { + return (await (await api.op("getMe", { user })).json())[0].personal_account_id; +} + +async function issue(user: string, scope: "read" | "read_write", expires_in_seconds = 3600) { + const { status, body } = await firstResult( + await api.op("createDelegation", { user, items: [{ client_name: "手元の Claude Code", scope, expires_in_seconds, idempotency_key: `d-${++keys}` }] }), + ); + expect(status).toBe(200); + return body as { delegation: { credential_id: string; expires_at: string }; credential: string }; +} + +const as = (credential: string) => ({ authorization: `Bearer ${credential}` }); +const agent = (credential: string, id: OperationId, opts: OpOptions = {}) => api.op(id, { ...opts, ...as(credential) }); + +describe("発行(I1・I3・I14)", () => { + it("値は ndfd__<秘密> で 1 度だけ返り、保存した行に平文が無く、ハッシュが値と一致する", async () => { + await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read"); + const m = /^ndfd_([a-z2-7]{26})_(.+)$/.exec(credential)!; + expect(m[1]).toBe(delegation.credential_id); + const row = api.delegationStore.records.get(delegation.credential_id)!; + expect(JSON.stringify(row)).not.toContain(m[2]); + expect(row.secretHash).toBe(createHash("sha256").update(m[2]!).digest("hex")); + const list = await (await api.op("listDelegations", { user: "u-1" })).json(); + expect(list).toEqual([expect.objectContaining({ credential_id: delegation.credential_id, scope: "read", state: "active", revoked_at: null })]); + expect(JSON.stringify(list)).not.toContain(m[2]); + }); + + it.each([299, 3601])("期限 %i 秒は 400 で保存しない", async (seconds) => { + await personal("u-1"); + const res = await firstResult(await api.op("createDelegation", { user: "u-1", items: [{ client_name: "x", scope: "read", expires_in_seconds: seconds, idempotency_key: "k" }] })); + expect(res.status).toBe(400); + expect(api.delegationStore.records.size).toBe(0); + }); + + it("同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を返さない", async () => { + await personal("u-1"); + const item = { client_name: "x", scope: "read", expires_in_seconds: 600, idempotency_key: "same" }; + const first = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] })); + const second = await firstResult(await api.op("createDelegation", { user: "u-1", items: [item] })); + expect(second.body.delegation.credential_id).toBe(first.body.delegation.credential_id); + expect(first.body.credential).toMatch(/^ndfd_/); + expect(second.body.credential).toBeNull(); + expect(api.delegationStore.records.size).toBe(1); + }); +}); + +describe("委任の資格情報での認証(受け入れ条件 10〜12、I2・I6)", () => { + it("人の token と同じ人の委任で、同じ操作の許可と拒否が一致する", async () => { + const own = await personal("u-1"); + const other = await personal("u-2"); + const { credential } = await issue("u-1", "read_write"); + const cases: [OperationId, OpOptions][] = [ + ["getAccount", { query: { account_id: [own] } }], + ["getAccount", { query: { account_id: [other] } }], + ["listSprintRuns", { accountId: own }], + ["listSprintRuns", { accountId: other }], + ["setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }], + ["setExecutionMode", { items: [{ account_id: other, execution_mode: "byok" }] }], + ]; + for (const [id, opts] of cases) { + const human = await firstResult(await api.op(id, { ...opts, user: "u-1" })); + const delegated = await firstResult(await agent(credential, id, opts)); + expect(delegated.status, `${id} ${JSON.stringify(opts)}`).toBe(human.status); + } + }); + + it("期限を過ぎた資格情報は 401 で、操作を行わない", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read_write", 300); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + api.clock.now = new Date(api.clock.now.getTime() + 300_000); + const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }); + expect(res.status).toBe(401); + expect(api.store.accounts.get(own)?.executionMode).toBe("local"); + }); + + it("取り消しの後の最初の要求が 401 で、照合の結果をためない", async () => { + const own = await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read"); + const reads = api.delegationStore.reads; + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(200); + expect(api.delegationStore.reads - reads).toBe(2); + const revoked = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } })); + expect(revoked.body.state).toBe("revoked"); + expect((await agent(credential, "listSprintRuns", { accountId: own })).status).toBe(401); + }); + + it("形の違う値・秘密の違う値・無い ID は 401", async () => { + await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const wrongSecret = credential.slice(0, -1) + (credential.endsWith("A") ? "B" : "A"); + for (const value of ["ndfd_x", wrongSecret, `ndfd_${"a".repeat(26)}_${"b".repeat(43)}`]) { + expect((await api.op("getMe", as(value))).status, value).toBe(401); + } + }); + + it("委任を使わない人の操作は、今と同じく token の検証の失敗が 401、アカウントの解決の失敗が 403(受け入れ条件 26)", async () => { + expect((await api.op("getMe", { authorization: "Bearer broken" })).status).toBe(401); + await personal("u-1"); + expect((await api.op("listSprintRuns", { user: "u-1", accountId: "00000000-0000-4000-8000-0000000000ff" })).status).toBe(403); + }); +}); + +describe("範囲と人だけが確定できる操作(受け入れ条件 13・14・16〜18、I4・I5)", () => { + it("読み取りだけの範囲で書き込みの操作は 403 scope_insufficient で、操作を行わない", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const res = await agent(credential, "setExecutionMode", { items: [{ account_id: own, execution_mode: "byok" }] }); + expect(res.status).toBe(403); + expect((await res.json()).error.code).toBe("scope_insufficient"); + expect(api.store.accounts.get(own)?.executionMode).toBe("local"); + }); + + it("委任の資格情報で委任の資格情報の発行を呼ぶと 403", async () => { + await personal("u-1"); + const { credential } = await issue("u-1", "read_write"); + const res = await agent(credential, "createDelegation", { items: [{ client_name: "y", scope: "read_write", expires_in_seconds: 600, idempotency_key: "z" }] }); + expect(res.status).toBe(403); + expect((await res.json()).error.code).toBe("human_only"); + expect(api.delegationStore.records.size).toBe(1); + }); + + it("扱い human の操作をすべて読み書きの範囲で呼ぶと 403 human_only で、何も保存されない", async () => { + const own = await personal("u-1"); + const { delegation, credential } = await issue("u-1", "read_write"); + const snapshot = () => + JSON.stringify([ + [...api.store.accounts.values()], + [...api.store.seats.values()], + api.store.history.length, + [...api.delegationStore.records.values()], + [...api.h!.table.loops.values()], + [...api.h!.table.credentials.values()], + [...api.secrets.values.values()], + ]); + const before = snapshot(); + const human = OPERATIONS.filter((op) => op.access === "human"); + expect(human).toHaveLength(10); + for (const op of human) { + const query = Object.fromEntries(op.keys.length && (op.kind === "select" || op.kind === "delete") ? [[op.keys[0]!, ["x"]]] : []); + if (op.operationId === "revokeDelegation") query.credential_id = [delegation.credential_id]; + const item = { user_id: "u-2", seat_type: "premium", name: "t", kind: "anthropic_api_key", api_key: "sk-SECRET", loop_id: "l", issue: 1, decision: "approved", repository_id: 1, seq: 1, choice: "reject", client_name: "c", scope: "read", expires_in_seconds: 600, idempotency_key: "h" }; + const hasBody = !["select", "delete"].includes(op.kind); + const res = await api.call(op.method, operationUrl(op, own, query), { ...as(credential), body: hasBody ? [item] : undefined }); + expect(res.status, op.operationId).toBe(403); + expect((await res.json()).error.code, op.operationId).toBe("human_only"); + } + expect(snapshot()).toBe(before); + }); + + it("委任の資格情報で、スプリントの詳細と承認待ちの一覧は読める", async () => { + const own = await personal("u-1"); + const { credential } = await issue("u-1", "read"); + const h = api.h!; + h.table.registrations.set(`${own}#101`, { accountId: own, repositoryId: 101, fullName: "o/r", installationId: 9, registeredAt: "2026-11-04T00:00:00.000Z" } as never); + const list = await firstResult(await agent(credential, "listRepositoryApprovalRequests", { accountId: own, query: { repository_id: [101] } })); + expect(list.status).toBe(200); + expect(list.body.items).toEqual([]); + const detail = await agent(credential, "getSprintRunDetail", { accountId: own, query: { sprint_run_id: ["sr-none"] } }); + expect(detail.status).toBe(200); + expect((await detail.json())[0].error.code).toBe("forbidden"); + }); +}); + +describe("取り消し(E9)", () => { + it("他人の資格情報は forbidden、期限切れの資格情報は何も変えずに成功", async () => { + await personal("u-1"); + await personal("u-2"); + const { delegation } = await issue("u-1", "read", 300); + const others = await firstResult(await api.op("revokeDelegation", { user: "u-2", query: { credential_id: [delegation.credential_id] } })); + expect(others.status).toBe(403); + api.clock.now = new Date(api.clock.now.getTime() + 301_000); + const expired = await firstResult(await api.op("revokeDelegation", { user: "u-1", query: { credential_id: [delegation.credential_id] } })); + expect(expired.body).toMatchObject({ state: "expired", revoked_at: null }); + expect(api.delegationStore.records.get(delegation.credential_id)?.revokedAt).toBeNull(); + }); +}); diff --git a/factory/api/src/delegations/memory-store.ts b/factory/api/src/delegations/memory-store.ts new file mode 100644 index 000000000..f4b4536a8 --- /dev/null +++ b/factory/api/src/delegations/memory-store.ts @@ -0,0 +1,45 @@ +// DynamoDelegationStore と同じ条件で書く、メモリの上の保存。テストと手元の確かめに使う +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; +import type { DelegationRecord } from "./model.ts"; +import type { DelegationStore } from "./store.ts"; + +export class MemoryDelegationStore implements DelegationStore { + readonly records = new Map(); + readonly idempotency = new Map(); + // 読み出しの回数(取り消しを次の要求から効かせるため、照合をためないことを確かめる) + reads = 0; + + async get(credentialId: string) { + this.reads += 1; + const r = this.records.get(credentialId); + return r ? { ...r } : null; + } + + async create(record: DelegationRecord, idempotency?: IdempotencyClaim) { + if (this.records.has(record.credentialId)) throw new Error("condition failed"); + if (idempotency) { + const row = this.idempotency.get(idempotencyPk(idempotency)); + if (row && row.ttl >= epochSeconds(idempotency.createdAt)) return "duplicate" as const; + this.idempotency.set(idempotencyPk(idempotency), { ...idempotency }); + } + this.records.set(record.credentialId, { ...record }); + return "created" as const; + } + + async listByUser(userId: string, limit: number) { + return [...this.records.values()] + .filter((r) => r.userId === userId) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0)) + .slice(0, limit) + .map((r) => ({ ...r })); + } + + async revoke(credentialId: string, at: string) { + const r = this.records.get(credentialId); + if (r && r.revokedAt === null) r.revokedAt = at; + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + return liveCreatedId(this.idempotency.get(idempotencyPk(ref)) ?? null, now); + } +} diff --git a/factory/api/src/delegations/model.ts b/factory/api/src/delegations/model.ts new file mode 100644 index 000000000..ee1582358 --- /dev/null +++ b/factory/api/src/delegations/model.ts @@ -0,0 +1,47 @@ +// 委任の資格情報(#2384 の集約「委任の資格情報」)。人が画面で発行し、エージェントかクライアントへ渡す +import type { Idp } from "../accounts/model.ts"; + +// read は読み取りだけ、read_write は読み書き。どちらも人だけが確定できる操作は呼べない(I4) +export type DelegationScope = "read" | "read_write"; +export type DelegationState = "active" | "expired" | "revoked"; + +export const DELEGATION_SCOPES: readonly DelegationScope[] = ["read", "read_write"]; +// 期限は発行から 300〜3,600 秒(I1) +export const MIN_EXPIRES_SECONDS = 300; +export const MAX_EXPIRES_SECONDS = 3600; +export const MAX_CLIENT_NAME = 64; +// 一覧で返す数 +export const LIST_LIMIT = 100; +// 値の頭。Cognito の token と分ける(決定 8) +export const CREDENTIAL_PREFIX = "ndfd_"; + +export interface DelegationRecord { + credentialId: string; + userId: string; + idp: Idp; + clientName: string; + scope: DelegationScope; + // 秘密の SHA-256 の 16 進。平文は持たない(I3) + secretHash: string; + createdAt: string; + expiresAt: string; + // null は取り消していない。取り消しで 1 度だけ書く + revokedAt: string | null; +} + +export function stateOf(record: DelegationRecord, now: string): DelegationState { + if (record.revokedAt !== null) return "revoked"; + return record.expiresAt <= now ? "expired" : "active"; +} + +export function delegationView(record: DelegationRecord, now: string) { + return { + credential_id: record.credentialId, + client_name: record.clientName, + scope: record.scope, + created_at: record.createdAt, + expires_at: record.expiresAt, + revoked_at: record.revokedAt, + state: stateOf(record, now), + }; +} diff --git a/factory/api/src/delegations/service.ts b/factory/api/src/delegations/service.ts new file mode 100644 index 000000000..7d38239c6 --- /dev/null +++ b/factory/api/src/delegations/service.ts @@ -0,0 +1,148 @@ +// 委任の管理(#2384 の F2・F3)。委任の資格情報の発行・一覧・取り消しと、値の照合。 +// 値は ndfd_<資格情報の ID>_<秘密> の不透明な値で、要求のたびに表を強い整合で読み、照合の結果をためない(決定 8) +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Caller, Idp } from "../accounts/model.ts"; +import { ForbiddenError, InvalidRequestError, ServiceUnavailableError } from "../errors.ts"; +import { claimOf, parseIdempotencyKey, type IdempotencyRef } from "../idempotency.ts"; +import { + CREDENTIAL_PREFIX, + DELEGATION_SCOPES, + LIST_LIMIT, + MAX_CLIENT_NAME, + MAX_EXPIRES_SECONDS, + MIN_EXPIRES_SECONDS, + delegationView, + stateOf, + type DelegationRecord, + type DelegationScope, +} from "./model.ts"; +import type { DelegationStore } from "./store.ts"; + +// RFC 4648 の base32 を小文字で、埋めの = を付けずに書く。16 バイトは 26 字 +const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"; +function base32(bytes: Buffer): string { + let bits = 0; + let value = 0; + let out = ""; + for (const b of bytes) { + value = (value << 8) | b; + bits += 8; + while (bits >= 5) { + out += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]; + return out; +} + +const VALUE = /^ndfd_([a-z2-7]{26})_([A-Za-z0-9_-]{43})$/; +const sha256 = (secret: string) => createHash("sha256").update(secret, "utf8").digest("hex"); + +// 照合が通った委任の資格情報。人の直の操作と同じ Caller を組むための値と、範囲を持つ +export interface VerifiedDelegation { + userId: string; + idp: Idp; + credentialId: string; + scope: DelegationScope; +} + +export interface DelegationDeps { + store: DelegationStore; + now?: () => Date; + random?: (n: number) => Buffer; +} + +export class DelegationService { + private readonly now: () => Date; + private readonly random: (n: number) => Buffer; + + constructor(private readonly deps: DelegationDeps) { + this.now = deps.now ?? (() => new Date()); + this.random = deps.random ?? randomBytes; + } + + private iso(): string { + return this.now().toISOString(); + } + + // 値の照合(E2・E10)。形が違う・無い・秘密が違う・期限切れ・取り消し済みはどれも null(呼ぶ側が 401 にする) + async verify(value: string): Promise { + const m = VALUE.exec(value); + if (!m) return null; + let record: DelegationRecord | null; + try { + record = await this.deps.store.get(m[1]!); + } catch { + throw new ServiceUnavailableError("delegation_unavailable"); + } + if (!record) return null; + const given = Buffer.from(sha256(m[2]!), "hex"); + const saved = Buffer.from(record.secretHash, "hex"); + if (given.length !== saved.length || !timingSafeEqual(given, saved)) return null; + if (stateOf(record, this.iso()) !== "active") return null; + return { userId: record.userId, idp: record.idp, credentialId: record.credentialId, scope: record.scope }; + } + + // 発行(E1)。値は結果で 1 度だけ返す。同じ冪等の鍵の 2 回目は同じ資格情報を返し、値を null にする(I14) + async issue(caller: Caller, element: Record) { + const clientName = element.client_name; + if (typeof clientName !== "string" || clientName.trim() === "" || clientName.length > MAX_CLIENT_NAME) { + throw new InvalidRequestError(`クライアントの名前は 1〜${MAX_CLIENT_NAME} 字にしてください`); + } + const scope = element.scope; + if (!DELEGATION_SCOPES.includes(scope as DelegationScope)) throw new InvalidRequestError("範囲は read か read_write です"); + const seconds = element.expires_in_seconds; + if (!Number.isInteger(seconds) || (seconds as number) < MIN_EXPIRES_SECONDS || (seconds as number) > MAX_EXPIRES_SECONDS) { + throw new InvalidRequestError(`期限は ${MIN_EXPIRES_SECONDS}〜${MAX_EXPIRES_SECONDS} 秒にしてください`, "invalid_expires_in"); + } + const ref: IdempotencyRef = { userId: caller.userId, operationId: "createDelegation", key: parseIdempotencyKey(element.idempotency_key) }; + const now = this.now(); + const replayed = await this.replay(ref, now.toISOString()); + if (replayed) return replayed; + const credentialId = base32(this.random(16)); + const secret = this.random(32).toString("base64url"); + const record: DelegationRecord = { + credentialId, + userId: caller.userId, + idp: caller.idp, + clientName, + scope: scope as DelegationScope, + secretHash: sha256(secret), + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + (seconds as number) * 1000).toISOString(), + revokedAt: null, + }; + const created = await this.deps.store.create(record, claimOf(ref, credentialId, record.createdAt)); + if (created === "duplicate") { + const won = await this.replay(ref, now.toISOString()); + if (!won) throw new ServiceUnavailableError("delegation_unavailable"); + return won; + } + return { delegation: delegationView(record, record.createdAt), credential: `${CREDENTIAL_PREFIX}${credentialId}_${secret}` }; + } + + private async replay(ref: IdempotencyRef, now: string) { + const id = await this.deps.store.getIdempotent(ref, now); + const record = id ? await this.deps.store.get(id) : null; + return record ? { delegation: delegationView(record, now), credential: null } : null; + } + + // 自分が発行したものだけを、新しい順で返す + async list(caller: Caller) { + const now = this.iso(); + return (await this.deps.store.listByUser(caller.userId, LIST_LIMIT)).map((r) => delegationView(r, now)); + } + + // 取り消し(E9)。期限切れと取り消し済みは何も変えずに成功を返す。他人の資格情報と無い資格情報は同じ forbidden + async revoke(caller: Caller, credentialId: unknown) { + const record = typeof credentialId === "string" ? await this.deps.store.get(credentialId) : null; + if (!record || record.userId !== caller.userId) throw new ForbiddenError(); + const now = this.iso(); + if (stateOf(record, now) === "active") { + await this.deps.store.revoke(record.credentialId, now); + return delegationView({ ...record, revokedAt: now }, now); + } + return delegationView(record, now); + } +} diff --git a/factory/api/src/delegations/store.ts b/factory/api/src/delegations/store.ts new file mode 100644 index 000000000..504fd4249 --- /dev/null +++ b/factory/api/src/delegations/store.ts @@ -0,0 +1,129 @@ +// 委任の資格情報の表への読み書き(#2384 の「データ構造」)。区分の鍵は credential_id だけで、利用者の一覧は GSI で引く +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, GetCommand, QueryCommand, TransactWriteCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; +import type { DelegationRecord } from "./model.ts"; + +export interface DelegationStore { + // 強い整合で読む(取り消しを次の要求から効かせる。I2) + get(credentialId: string): Promise; + // 行が無いときだけ書く。idempotency があれば鍵の行も同じトランザクションで書き、期限の中の同じ鍵があれば duplicate + create(record: DelegationRecord, idempotency?: IdempotencyClaim): Promise<"created" | "duplicate">; + // 利用者が発行した資格情報の新しい順 + listByUser(userId: string, limit: number): Promise; + // revokedAt が空のときだけ 1 度だけ書く。既に書いてあれば何もしない + revoke(credentialId: string, at: string): Promise; + getIdempotent(ref: IdempotencyRef, now: string): Promise; +} + +// GSI の名前(インフラが作る) +export const USER_INDEX = "user_id-created_at"; + +type Item = Record; + +function toItem(r: DelegationRecord): Item { + return { + credential_id: r.credentialId, + user_id: r.userId, + idp: r.idp, + client_name: r.clientName, + scope: r.scope, + secret_hash: r.secretHash, + created_at: r.createdAt, + expires_at: r.expiresAt, + ...(r.revokedAt === null ? {} : { revoked_at: r.revokedAt }), + }; +} + +function toRecord(i: Item): DelegationRecord { + return { + credentialId: String(i.credential_id), + userId: String(i.user_id), + idp: i.idp as DelegationRecord["idp"], + clientName: String(i.client_name), + scope: i.scope as DelegationRecord["scope"], + secretHash: String(i.secret_hash), + createdAt: String(i.created_at), + expiresAt: String(i.expires_at), + revokedAt: typeof i.revoked_at === "string" ? i.revoked_at : null, + }; +} + +const isCancelled = (err: unknown) => err instanceof Error && err.name === "TransactionCanceledException"; +const isConditionFailed = (err: unknown) => err instanceof Error && err.name === "ConditionalCheckFailedException"; + +export class DynamoDelegationStore implements DelegationStore { + private readonly doc: DynamoDBDocumentClient; + + constructor( + private readonly tableName: string, + client: DynamoDBClient = new DynamoDBClient({}), + ) { + this.doc = DynamoDBDocumentClient.from(client); + } + + async get(credentialId: string) { + const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: credentialId }, ConsistentRead: true })); + const item = out.Item as Item | undefined; + return item && typeof item.secret_hash === "string" ? toRecord(item) : null; + } + + async create(record: DelegationRecord, idempotency?: IdempotencyClaim) { + const items: Item[] = [{ Put: { TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(credential_id)" } }]; + if (idempotency) { + items.push({ + Put: { + TableName: this.tableName, + Item: { credential_id: idempotencyPk(idempotency), createdId: idempotency.createdId, createdAt: idempotency.createdAt, ttl: idempotency.ttl }, + ConditionExpression: "attribute_not_exists(credential_id) OR #ttl < :now", + ExpressionAttributeNames: { "#ttl": "ttl" }, + ExpressionAttributeValues: { ":now": epochSeconds(idempotency.createdAt) }, + }, + }); + } + try { + await this.doc.send(new TransactWriteCommand({ TransactItems: items as never })); + return "created" as const; + } catch (err) { + // 資格情報の ID は 16 バイトの乱数で重ならないため、条件の失敗は冪等の鍵の行 + if (idempotency && isCancelled(err)) return "duplicate" as const; + throw err; + } + } + + async listByUser(userId: string, limit: number) { + const out = await this.doc.send( + new QueryCommand({ + TableName: this.tableName, + IndexName: USER_INDEX, + KeyConditionExpression: "user_id = :u", + ExpressionAttributeValues: { ":u": userId }, + ScanIndexForward: false, + Limit: limit, + }), + ); + return (out.Items ?? []).map((i) => toRecord(i as Item)); + } + + async revoke(credentialId: string, at: string) { + try { + await this.doc.send( + new UpdateCommand({ + TableName: this.tableName, + Key: { credential_id: credentialId }, + UpdateExpression: "SET revoked_at = :at", + ConditionExpression: "attribute_exists(credential_id) AND attribute_not_exists(revoked_at)", + ExpressionAttributeValues: { ":at": at }, + }), + ); + } catch (err) { + if (!isConditionFailed(err)) throw err; + } + } + + async getIdempotent(ref: IdempotencyRef, now: string) { + const out = await this.doc.send(new GetCommand({ TableName: this.tableName, Key: { credential_id: idempotencyPk(ref) }, ConsistentRead: true })); + const item = out.Item as Item | undefined; + return liveCreatedId(item ? { createdId: String(item.createdId), ttl: Number(item.ttl) } : null, now); + } +} diff --git a/factory/api/src/errors.ts b/factory/api/src/errors.ts index 4401619c9..79e6dd340 100644 --- a/factory/api/src/errors.ts +++ b/factory/api/src/errors.ts @@ -30,6 +30,20 @@ export class ForbiddenError extends ApiError { } } +// 委任の資格情報で、人だけが確定できる操作を呼んだ(#2384 の I4) +export class HumanOnlyError extends ApiError { + constructor() { + super(403, "human_only", "この操作は人が画面で確定します。委任の資格情報からは行えません"); + } +} + +// 範囲が読み取りだけの委任の資格情報で、書き込みの操作を呼んだ(#2384 の I5) +export class ScopeError extends ApiError { + constructor() { + super(403, "scope_insufficient", "読み取りだけの委任では、この操作を行えません"); + } +} + export class InvalidRequestError extends ApiError { constructor(message = "要求の形が正しくありません", code = "invalid_request", details?: Record) { super(400, code, message, details); diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index 89fc9efe5..d4c73c04c 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -9,6 +9,9 @@ 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 { registerFactory, type FactoryDeps } from "../routes/factory.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; @@ -69,6 +72,7 @@ export function createApiApp( registerMe(app); registerTeams(app); registerAccounts(app); + registerDelegations(app); if (factory) registerFactory(app, factory); return app; } @@ -89,6 +93,8 @@ export function depsFromEnv(): AuthDeps { clientId: requireEnv("COGNITO_CLIENT_ID"), }), accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }), + // 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする + delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null, }; return cached; } diff --git a/factory/api/src/routes/delegations.ts b/factory/api/src/routes/delegations.ts new file mode 100644 index 000000000..c6bf1c2a4 --- /dev/null +++ b/factory/api/src/routes/delegations.ts @@ -0,0 +1,19 @@ +// エージェントへの委任の操作(openapi/delegations.yaml。#2384 の F2)。委任は利用者に属し、アカウントに属さない。 +// 発行と取り消しは人だけが確定でき(扱い human)、委任の資格情報からは操作の登録の層が断る +import type { Context, Hono } from "hono"; +import type { AuthEnv } from "../auth/resolve.ts"; +import type { DelegationService } from "../delegations/service.ts"; +import { ServiceUnavailableError } from "../errors.ts"; +import { registerList, registerOperation } from "./operation.ts"; + +function service(c: Context): DelegationService { + const d = c.get("auth").delegations; + if (!d) throw new ServiceUnavailableError("delegation_unavailable"); + return d; +} + +export function registerDelegations(app: Hono): void { + registerList(app, "listDelegations", (c) => service(c).list(c.get("caller"))); + registerOperation(app, "createDelegation", { run: (e, _ctx, _s, c) => service(c).issue(c.get("caller"), e) }); + registerOperation(app, "revokeDelegation", { run: (e, _ctx, _s, c) => service(c).revoke(c.get("caller"), e.credential_id) }); +} diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts index bad0a7713..989f28f90 100644 --- a/factory/api/src/routes/operation.ts +++ b/factory/api/src/routes/operation.ts @@ -4,7 +4,7 @@ import type { Context, Hono } from "hono"; import { MAX_ITEMS, operation, type Operation, type OperationId } from "@ndf-factory/contracts/operations"; import type { AccountContext } from "../accounts/model.ts"; import { resolveFromRequest, type AuthEnv, type OperationTrace } from "../auth/resolve.ts"; -import { InvalidRequestError } from "../errors.ts"; +import { HumanOnlyError, InvalidRequestError, ScopeError } from "../errors.ts"; import { runElements, type ElementResult } from "./elements.ts"; export type Element = Record; @@ -25,9 +25,18 @@ async function contextOf(c: Context, op: Operation): Promise, op: Operation): void { + const delegation = c.get("caller").delegation; + if (!delegation) return; + if (op.access === "human") throw new HumanOnlyError(); + if (op.access === "write" && delegation.scope !== "read_write") throw new ScopeError(); +} + function trace(c: Context, op: Operation): OperationTrace { const t: OperationTrace = { operationId: op.operationId, accountId: op.path.includes("{accountId}") ? (c.req.param("accountId") ?? null) : null, targetIds: [], reasons: [] }; c.set("operation", t); + guard(c, op); return t; } diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts index 28f2afeef..d093aa207 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -1,8 +1,33 @@ import { operation, operationUrl, type OperationId } from "@ndf-factory/contracts/operations"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; +import { MemoryDelegationStore } from "../delegations/memory-store.ts"; +import { DelegationService } from "../delegations/service.ts"; import { createApiApp, type LogEntry } from "../handlers/api.ts"; import { createTokenKit, type TokenKit } from "./tokens.ts"; +import type { FactoryDeps } from "../routes/factory.ts"; +import { CredentialService, type SecretsPort } from "../credentials/service.ts"; +import { RepositoryService } from "../repositories/service.ts"; +import { memoryProgressReader } from "../progress/read.ts"; +import { LoopControl } from "../runs/loops.ts"; +import { createRunsHarness, type RunsHarness } from "./runs.ts"; + +// 秘密の置き場の偽物(値はメモリだけに置く) +export class FakeSecrets implements SecretsPort { + readonly values = new Map(); + private n = 0; + async create(name: string, value: string) { + const arn = `arn:aws:secretsmanager:us-east-1:111111111111:secret:${name}-${++this.n}`; + this.values.set(arn, value); + return arn; + } + async put(arn: string, value: string) { + this.values.set(arn, value); + } + async delete(arn: string) { + this.values.delete(arn); + } +} // テストで本文を読みやすくするため、json() の型を any にした応答 export type TestResponse = Omit & { json(): Promise }; @@ -35,23 +60,60 @@ export interface TestApi { app: ReturnType; store: MemoryAccountStore; service: AccountService; + delegationStore: MemoryDelegationStore; + delegations: DelegationService; + // 時刻を進められる時計(委任の期限の確かめ) + clock: { now: Date }; + // factory を求めたときだけ。実行の表・GitHub・秘密の置き場の偽物 + h: RunsHarness | null; + secrets: FakeSecrets; tokens: TokenKit; logs: LogEntry[]; call(method: string, path: string, opts?: { user?: string; idp?: "Google" | "GitHub"; body?: unknown; authorization?: string }): Promise; op(operationId: OperationId, opts?: OpOptions): Promise; } -export async function createTestApi(): Promise { +export async function createTestApi(opts: { factory?: boolean } = {}): Promise { const store = new MemoryAccountStore(); let n = 0; const service = new AccountService({ store, newId: () => `00000000-0000-4000-8000-${String(++n).padStart(12, "0")}` }); const tokens = await createTokenKit(); const logs: LogEntry[] = []; - const app = createApiApp(() => ({ verifier: tokens.verifier, accounts: service }), (e) => logs.push(e)); + const clock = { now: new Date("2026-11-04T00:00:00.000Z") }; + const delegationStore = new MemoryDelegationStore(); + const delegations = new DelegationService({ store: delegationStore, now: () => clock.now }); + const secrets = new FakeSecrets(); + const h = opts.factory ? await createRunsHarness() : null; + let factory: FactoryDeps | null = null; + if (h) { + const key = new TextEncoder().encode("proof-key-for-tests-0123456789ab"); + factory = { + repositories: new RepositoryService({ table: h.table, github: h.github, proofKey: async () => key }), + credentials: new CredentialService(h.table, secrets), + control: h.control, + loops: new LoopControl({ table: h.table, control: h.control, reader: memoryProgressReader(h.progress), now: () => h.clock.now }), + organizer: h.organizer, + table: h.table, + github: h.github, + candidateModel: async (_s, issues) => issues.map((i) => ({ number: i.number, selected: true, reason: "理由" })), + reader: memoryProgressReader(h.progress), + }; + } + const app = createApiApp( + () => ({ verifier: tokens.verifier, accounts: service, delegations }), + (e) => logs.push(e), + () => null, + factory && (() => factory!), + ); const api: TestApi = { app, store, service, + delegationStore, + delegations, + clock, + h, + secrets, tokens, logs, async call(method, path, opts = {}) { diff --git a/factory/web/src/App.tsx b/factory/web/src/App.tsx index 71b92d522..0249b33d6 100644 --- a/factory/web/src/App.tsx +++ b/factory/web/src/App.tsx @@ -20,6 +20,7 @@ import { SprintDetailPage } from "./pages/SprintDetailPage.tsx"; import { BacklogPage } from "./pages/BacklogPage.tsx"; import { LoopsPage } from "./pages/LoopsPage.tsx"; import { LoopDetailPage } from "./pages/LoopDetailPage.tsx"; +import { DelegationsPage } from "./pages/DelegationsPage.tsx"; // 地図の available の行の画面。経路は screens.ts だけが持つ const ELEMENTS: Partial> = { @@ -36,6 +37,7 @@ const ELEMENTS: Partial> = { backlog: , loops: , loopDetail: , + delegations: , }; export interface AppProps { diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts index 5552287d9..b3aa988b1 100644 --- a/factory/web/src/api.test.ts +++ b/factory/web/src/api.test.ts @@ -62,6 +62,9 @@ const CALLS: { [K in keyof Api]: (api: Api) => Promise } = { cancelLoop: (a) => a.cancelLoop("p-1", "l1"), requestNextLap: (a) => a.requestNextLap("p-1", "l1"), answerLoop: (a) => a.answerLoop("p-1", "l1", 3, "approved"), + delegations: (a) => a.delegations(), + createDelegation: (a) => a.createDelegation({ client_name: "c", scope: "read", expires_in_seconds: 900 }), + revokeDelegation: (a) => a.revokeDelegation("d1"), }; describe("画面の API クライアントと契約(受け入れ条件 1)", () => { diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index a5a9c7927..873d61929 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -231,6 +231,25 @@ export interface LoopDetail { pending: LoopPending[]; } +// エージェントへの委任(factory/contracts/openapi/delegations.yaml。#2384) +export type DelegationScope = "read" | "read_write"; + +export interface Delegation { + credential_id: string; + client_name: string; + scope: DelegationScope; + created_at: string; + expires_at: string; + revoked_at: string | null; + state: "active" | "expired" | "revoked"; +} + +export interface DelegationInput { + client_name: string; + scope: DelegationScope; + expires_in_seconds: number; +} + export interface Api { me(): Promise; account(id: string): Promise; @@ -269,6 +288,11 @@ export interface Api { cancelLoop(id: string, loopId: string): Promise<{ state: LoopState }>; requestNextLap(id: string, loopId: string): Promise<{ state: LoopState }>; answerLoop(id: string, loopId: string, issue: number, decision: LoopDecision, pr?: number): Promise<{ pending: LoopPending[] }>; + // 自分が発行した委任の資格情報(新しい順) + delegations(): Promise; + // 値(credential)は発行の結果で 1 度だけ返る + createDelegation(input: DelegationInput): Promise<{ delegation: Delegation; credential: string | null }>; + revokeDelegation(credentialId: string): Promise; } // 要素の結果(#2384)。入力と同じ順・同じ数で返る @@ -372,5 +396,8 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. cancelLoop: (id, loop_id) => one("cancelLoop", id, { loop_id }), requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }), answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }), + delegations: () => list("listDelegations", null), + createDelegation: (input) => one("createDelegation", null, { ...input, idempotency_key: newKey() }), + revokeDelegation: async (credentialId) => (await values("revokeDelegation", null, "credential_id", [credentialId]))[0]!, }; } diff --git a/factory/web/src/context.tsx b/factory/web/src/context.tsx index fbe53641a..f96393dc3 100644 --- a/factory/web/src/context.tsx +++ b/factory/web/src/context.tsx @@ -12,6 +12,8 @@ export interface Services { connection: ConnectionSource; // 一覧と詳細を読み直す間隔(ミリ秒)。無ければ既定の 30 秒 pollIntervalMs?: number; + // API の入口。委任の画面が MCP のクライアントへの登録の例に出す + apiUrl?: string; } export const ServicesContext = createContext(null); diff --git a/factory/web/src/dummy/api.ts b/factory/web/src/dummy/api.ts index 1e00b781e..09a56f631 100644 --- a/factory/web/src/dummy/api.ts +++ b/factory/web/src/dummy/api.ts @@ -1,4 +1,4 @@ -import { ApiFailure, type Api } from "../api.ts"; +import { ApiFailure, type Api, type Delegation } from "../api.ts"; import * as data from "./data.ts"; // 場面: normal は決まったデータ、empty は一覧が 0 件、error は読み込みがすべて失敗する @@ -15,6 +15,8 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); return found; }; + // 発行した委任はこの画面の間だけ持つ(値はダミーで、本物の API へは届かない) + const delegations: Delegation[] = scenario === "empty" ? [] : structuredClone(data.delegations); const loopOf = (loopId: string) => { const found = data.loopDetails.find((d) => d.loop.loop_id === loopId); if (!found) throw new ApiFailure(404, "loop_not_found", "loop_not_found"); @@ -78,5 +80,26 @@ export function createDummyApi(scenario: Scenario = "normal"): Api { requestNextLap: () => read(() => ({ state: "running" as const })), answerLoop: (_id, loopId, issue, decision, pr) => read(() => ({ pending: loopOf(loopId).pending.map((p) => (p.number === issue ? { ...p, answer: { decision, pr: pr ?? null } } : p)) })), + delegations: () => read(() => delegations), + createDelegation: async (input) => { + const now = new Date(); + const delegation: Delegation = { + credential_id: `dummy${String(delegations.length + 1).padStart(21, "0")}`, + client_name: input.client_name, + scope: input.scope, + created_at: now.toISOString(), + expires_at: new Date(now.getTime() + input.expires_in_seconds * 1000).toISOString(), + revoked_at: null, + state: "active", + }; + delegations.unshift(delegation); + return read(() => ({ delegation, credential: `ndfd_${delegation.credential_id}_${data.DUMMY_MARKER}` })); + }, + revokeDelegation: async (credentialId) => { + const found = delegations.find((d) => d.credential_id === credentialId); + if (!found) throw new ApiFailure(403, "forbidden", "forbidden"); + if (found.state === "active") Object.assign(found, { state: "revoked", revoked_at: new Date().toISOString() }); + return read(() => found); + }, }; } diff --git a/factory/web/src/dummy/data.ts b/factory/web/src/dummy/data.ts index 2c42cf860..0202d780a 100644 --- a/factory/web/src/dummy/data.ts +++ b/factory/web/src/dummy/data.ts @@ -1,4 +1,4 @@ -import type { AccountDetail, Backlog, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; +import type { AccountDetail, Backlog, Delegation, IssueCandidates, LoopDetail, Me, ModelCredential, Repository, SprintRunDetail, SprintRunRow, StepDetail, StepRow } from "../api.ts"; // ダミーデータ。本番の成果物にこの印が出たら check:bundle が落とす。実在の人・秘密を書かない export const DUMMY_MARKER = "ndf-factory-dummy-data"; @@ -281,3 +281,16 @@ export const loopDetails: LoopDetail[] = [ pending: [], }, ]; + +// 委任の資格情報の一覧(値は持たない) +export const delegations: Delegation[] = [ + { + credential_id: "dummy000000000000000000001", + client_name: "見本の Claude Code", + scope: "read", + created_at: "2026-11-04T00:00:00.000Z", + expires_at: "2026-11-04T01:00:00.000Z", + revoked_at: null, + state: "expired", + }, +]; diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json index b07cc7b59..566cfd820 100644 --- a/factory/web/src/i18n/ja.json +++ b/factory/web/src/i18n/ja.json @@ -29,7 +29,8 @@ "billing": "請求と使用量", "runners": "手元のマシン", "loops": "ループ", - "loopDetail": "ループの詳細" + "loopDetail": "ループの詳細", + "delegations": "エージェントへの委任" }, "connection": { "label": "つながりの状態", @@ -390,5 +391,32 @@ "exitCode": "終了コード", "back": "リポジトリへ戻る", "issueLine": "#{{number}} {{title}}" + }, + "delegations": { + "intro": "手元の Claude Code などのエージェントに、あなたの代わりに Factory を操作させる資格情報を発行します。承認と、キー・席・チーム・委任そのものの操作は、委任からは行えません。", + "clientName": "クライアントの名前", + "needClientName": "名前を入力してください", + "scope": "範囲", + "scopeRead": "読み取りだけ", + "scopeReadWrite": "読み書き", + "expires": "期限", + "minutes": "{{count}} 分", + "hours": "{{count}} 時間", + "issue": "発行する", + "issuedTitle": "発行した資格情報", + "issuedNote": "この値は今だけ表示します。閉じると二度と表示しません。", + "value": "資格情報の値", + "registerExample": "MCP のクライアントへの登録の例", + "close": "閉じる", + "list": "発行した資格情報", + "empty": "まだ委任していません", + "createdAt": "発行", + "expiresAt": "期限", + "state": "状態", + "stateActive": "有効", + "stateExpired": "期限切れ", + "stateRevoked": "取り消し済み", + "revoke": "取り消す", + "offline": "オフラインの間は発行と取り消しができません" } } diff --git a/factory/web/src/main.tsx b/factory/web/src/main.tsx index 16f3b25c3..37573693f 100644 --- a/factory/web/src/main.tsx +++ b/factory/web/src/main.tsx @@ -20,7 +20,7 @@ async function buildServices(): Promise { } const config = embeddedConfig(); const auth = createAuth(config); - return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs }; + return { auth, api: createApi(config.apiUrl, auth), connection: browserConnection(), pollIntervalMs: config.pollIntervalMs, apiUrl: config.apiUrl }; } const style = document.createElement("style"); diff --git a/factory/web/src/pages/AccountPage.tsx b/factory/web/src/pages/AccountPage.tsx index ec8930929..79fe28586 100644 --- a/factory/web/src/pages/AccountPage.tsx +++ b/factory/web/src/pages/AccountPage.tsx @@ -57,6 +57,9 @@ export function AccountPage() {

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

+

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

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

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

{t("delegations.intro")}

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

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

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

{t("delegations.offline")}

} + {message && {message}} +
+ {loadError ? ( + void load()} /> + ) : !rows ? ( + + ) : rows.length === 0 ? ( + + ) : ( + d.credential_id} + columns={[ + { key: "name", header: t("delegations.clientName"), render: (d) => d.client_name }, + { key: "scope", header: t("delegations.scope"), render: (d) => (d.scope === "read" ? t("delegations.scopeRead") : t("delegations.scopeReadWrite")) }, + { key: "created", header: t("delegations.createdAt"), render: (d) => format.dateTime(d.created_at) }, + { key: "expires", header: t("delegations.expiresAt"), render: (d) => format.dateTime(d.expires_at) }, + { key: "state", header: t("delegations.state"), render: (d) => }, + { + key: "revoke", + header: "", + render: (d) => + d.state === "active" ? ( + + ) : null, + }, + ]} + /> + )} +
+ + ); +} diff --git a/factory/web/src/pages/delegations.test.tsx b/factory/web/src/pages/delegations.test.tsx new file mode 100644 index 000000000..52b4b043c --- /dev/null +++ b/factory/web/src/pages/delegations.test.tsx @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { App } from "../App.tsx"; +import { createDummyServices } from "../dummy/index.ts"; +import type { LocaleSettings } from "../i18n/settings.ts"; + +afterEach(cleanup); + +const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" }; + +function show(search = "") { + render( + + + , + ); +} + +describe("エージェントへの委任(#2384)", () => { + it("発行すると値を 1 度だけ出し、閉じると二度と出さない。一覧の先頭に有効な行が足され、取り消せる", async () => { + show(); + await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code"); + await userEvent.click(screen.getByRole("button", { name: "発行する" })); + const value = (await screen.findByText(/^ndfd_/)).textContent!; + expect(screen.getByLabelText("MCP のクライアントへの登録の例").textContent).toContain(`Bearer ${value}`); + await userEvent.click(screen.getByRole("button", { name: "閉じる" })); + expect(document.body.textContent).not.toContain(value); + const rows = within(screen.getByRole("table", { name: "発行した資格情報" })).getAllByRole("row").slice(1); + expect(within(rows[0]!).getByText("有効")).toBeTruthy(); + expect(within(rows[1]!).getByText("期限切れ")).toBeTruthy(); + expect(within(rows[1]!).queryByRole("button", { name: "取り消す" })).toBeNull(); + await userEvent.click(within(rows[0]!).getByRole("button", { name: "取り消す" })); + expect(await within(rows[0]!).findByText("取り消し済み")).toBeTruthy(); + }); + + it("名前が無ければ発行しない", async () => { + show(); + await userEvent.click(await screen.findByRole("button", { name: "発行する" })); + expect(screen.getByRole("alert").textContent).toBe("名前を入力してください"); + }); + + it("オフラインの間は発行のボタンを押せない", async () => { + show("?connection=offline"); + expect(((await screen.findByRole("button", { name: "発行する" })) as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText("オフラインの間は発行と取り消しができません")).toBeTruthy(); + }); + + it("まだ委任していなければ空の表示", async () => { + show("?scenario=empty"); + expect(await screen.findByText("まだ委任していません")).toBeTruthy(); + }); +}); diff --git a/factory/web/src/screens.ts b/factory/web/src/screens.ts index 6bf1dec0c..b0660841f 100644 --- a/factory/web/src/screens.ts +++ b/factory/web/src/screens.ts @@ -21,7 +21,8 @@ export type ScreenId = | "billing" | "runners" | "loops" - | "loopDetail"; + | "loopDetail" + | "delegations"; export interface Screen { id: ScreenId; @@ -52,6 +53,8 @@ export const SCREENS: readonly Screen[] = [ { id: "runners", path: null, roles: OWNERS, nav: false, status: "planned" }, { id: "loops", path: "/accounts/:accountId/repositories/:repositoryId/loops", roles: ALL_ROLES, nav: false, status: "available" }, { id: "loopDetail", path: "/accounts/:accountId/loops/:loopId", roles: ALL_ROLES, nav: false, status: "available" }, + // 委任は利用者に属し、アカウントに属さない。自分のアカウントからたどる(#2384) + { id: "delegations", path: "/delegations", roles: ALL_ROLES, nav: false, status: "available" }, ]; export type AvailableScreen = Screen & { path: string }; From 088db4551075b1cb9cc8b3d97a8d5e45971854ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:40:32 +0000 Subject: [PATCH 16/39] =?UTF-8?q?=E5=A7=94=E4=BB=BB=E3=81=AE=E6=A4=9C?= =?UTF-8?q?=E6=9F=BB=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E5=9E=8B?= =?UTF-8?q?=E3=81=AE=E8=AA=A4=E3=82=8A=E3=82=92=E7=9B=B4=E3=81=99=EF=BC=88?= =?UTF-8?q?#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- factory/api/src/delegations/delegations.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/factory/api/src/delegations/delegations.test.ts b/factory/api/src/delegations/delegations.test.ts index 6972288f8..99473bf14 100644 --- a/factory/api/src/delegations/delegations.test.ts +++ b/factory/api/src/delegations/delegations.test.ts @@ -152,10 +152,11 @@ describe("範囲と人だけが確定できる操作(受け入れ条件 13・1 const human = OPERATIONS.filter((op) => op.access === "human"); expect(human).toHaveLength(10); for (const op of human) { - const query = Object.fromEntries(op.keys.length && (op.kind === "select" || op.kind === "delete") ? [[op.keys[0]!, ["x"]]] : []); + const byQuery = ["select", "delete"].includes(op.kind); + const query = Object.fromEntries(op.keys.length && byQuery ? [[op.keys[0]!, ["x"]]] : []); if (op.operationId === "revokeDelegation") query.credential_id = [delegation.credential_id]; const item = { user_id: "u-2", seat_type: "premium", name: "t", kind: "anthropic_api_key", api_key: "sk-SECRET", loop_id: "l", issue: 1, decision: "approved", repository_id: 1, seq: 1, choice: "reject", client_name: "c", scope: "read", expires_in_seconds: 600, idempotency_key: "h" }; - const hasBody = !["select", "delete"].includes(op.kind); + const hasBody = !byQuery; const res = await api.call(op.method, operationUrl(op, own, query), { ...as(credential), body: hasBody ? [item] : undefined }); expect(res.status, op.operationId).toBe(403); expect((await res.json()).error.code, op.operationId).toBe("human_only"); From c3dd76d3fdec3ebffef78f6bcdf618ea8339f125 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 22:43:11 +0000 Subject: [PATCH 17/39] =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E5=90=91?= =?UTF-8?q?=E3=81=91=E3=81=AE=E6=93=8D=E4=BD=9C=E3=82=92=201=20=E5=9B=9E?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AB=E6=93=8D=E4=BD=9C=E3=81=AE=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=B8=E8=BF=BD=E8=A8=98=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 認証より外の中間処理が、成功・一部の失敗・400・401・403・404 のどれでも記録を 1 件追記する。 記録は許可した鍵の値と固定の列だけで組み、要求の本文を写さない。呼び出し元の種類は資格情報の種類と MCP の内部の印だけで決める。追記に失敗したら記録の全体を構造化ログへ出し、503 audit_unavailable を返す。 保存の実装は追記の関数だけを持ち、表が無い間は構造化ログへ出す。ログの行に要求の識別子を足した。 Co-Authored-By: Claude Opus 5.5 --- factory/api/src/audit/audit.test.ts | 131 ++++++++++++++++++++++++ factory/api/src/audit/dynamo-log.ts | 19 ++++ factory/api/src/audit/log.ts | 76 ++++++++++++++ factory/api/src/audit/memory-log.ts | 14 +++ factory/api/src/audit/middleware.ts | 87 ++++++++++++++++ factory/api/src/audit/structured-log.ts | 11 ++ factory/api/src/auth/resolve.ts | 5 +- factory/api/src/auth/verify.test.ts | 2 +- factory/api/src/handlers/api.ts | 11 ++ factory/api/src/testing/app.ts | 6 +- 10 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 factory/api/src/audit/audit.test.ts create mode 100644 factory/api/src/audit/dynamo-log.ts create mode 100644 factory/api/src/audit/log.ts create mode 100644 factory/api/src/audit/memory-log.ts create mode 100644 factory/api/src/audit/middleware.ts create mode 100644 factory/api/src/audit/structured-log.ts diff --git a/factory/api/src/audit/audit.test.ts b/factory/api/src/audit/audit.test.ts new file mode 100644 index 000000000..cc6a50caa --- /dev/null +++ b/factory/api/src/audit/audit.test.ts @@ -0,0 +1,131 @@ +// 操作の記録(#2384 の受け入れ条件 19〜23、不変条件 I7〜I9・I16) +import { beforeEach, describe, expect, it } from "vitest"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; +import { DynamoAuditLog } from "./dynamo-log.ts"; +import { AUDIT_COLUMNS } from "./log.ts"; +import { MemoryAuditLog } from "./memory-log.ts"; +import { StructuredLogAudit } from "./structured-log.ts"; + +let api: TestApi; +beforeEach(async () => { + api = await createTestApi({ factory: true }); +}); + +async function personal(user: string): Promise { + return (await (await api.op("getMe", { user })).json())[0].personal_account_id; +} + +const last = () => api.audit.items.at(-1)!; + +describe("1 回の操作に 1 件(受け入れ条件 19・20、I7)", () => { + it("成功の記録は 7 項目と結果を持ち、要求の識別子でログの行とたどれる", async () => { + const acc = await personal("u-1"); + const before = api.audit.items.length; + await api.op("listSprintRuns", { user: "u-1", accountId: acc }); + expect(api.audit.items.length).toBe(before + 1); + expect(last()).toEqual({ + record_id: expect.any(String), + at: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/), + user_id: "u-1", + credential_id: null, + via: "screen", + operation_id: "listSprintRuns", + account_id: acc, + target_ids: [], + outcome: "ok", + status: 200, + reason: null, + }); + expect(api.logs.at(-1)!.request_id).toBe(last().record_id); + }); + + it("一部の要素が失敗すると partial と、失敗の code を重ねずに持つ。対象の ID は要素の鍵", async () => { + const acc = await personal("u-1"); + await api.call("GET", `/accounts/${acc}/sprint-runs/detail?sprint_run_id=sr-a&sprint_run_id=sr-b`, { user: "u-1" }); + expect(last()).toMatchObject({ outcome: "partial", reason: "forbidden", status: 200, target_ids: ["sprint_run_id=sr-a", "sprint_run_id=sr-b"] }); + }); + + it("読み取りも、401・403・400 で断った要求も 1 件ずつ残る", async () => { + const acc = await personal("u-1"); + await personal("u-2"); + const before = api.audit.items.length; + await api.op("getMe", { authorization: "Bearer broken" }); + expect(last()).toMatchObject({ outcome: "rejected", status: 401, reason: "unauthorized", user_id: null, operation_id: "getMe", via: "screen" }); + await api.op("listSprintRuns", { user: "u-2", accountId: acc }); + expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "forbidden", user_id: "u-2", operation_id: "listSprintRuns", account_id: acc }); + await api.call("GET", `/accounts/${acc}/sprint-runs/detail`, { user: "u-1" }); + expect(last()).toMatchObject({ outcome: "rejected", status: 400, reason: "empty_items" }); + await api.call("GET", "/nowhere", { user: "u-1" }); + expect(last()).toMatchObject({ status: 404, operation_id: null }); + expect(api.audit.items.length).toBe(before + 4); + }); + + it("委任の資格情報での要求は資格情報の ID と api を持ち、断った人だけの操作も残る", async () => { + const acc = await personal("u-1"); + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: "見本のクライアント", scope: "read", expires_in_seconds: 600, idempotency_key: "a" }] }), + ); + expect(last()).toMatchObject({ operation_id: "createDelegation", target_ids: ["idempotency_key=a", `credential_id=${issued.body.delegation.credential_id}`] }); + const auth = `Bearer ${issued.body.credential}`; + await api.op("listSprintRuns", { authorization: auth, accountId: acc }); + expect(last()).toMatchObject({ via: "api", credential_id: issued.body.delegation.credential_id, user_id: "u-1", outcome: "ok" }); + await api.op("putModelCredential", { authorization: auth, accountId: acc, items: [{ kind: "anthropic_api_key", api_key: "k" }] }); + expect(last()).toMatchObject({ outcome: "rejected", status: 403, reason: "human_only" }); + }); + + it("外から付けた呼び出し元のヘッダーでは種類が変わらない(I16)", async () => { + await personal("u-1"); + const res = await api.app.request("/me", { headers: { Authorization: await api.tokens.bearer("u-1"), "x-ndf-via": "mcp" } }); + expect(res.status).toBe(200); + expect(last().via).toBe("screen"); + }); + + it("追記に失敗したら 503 audit_unavailable を返す", async () => { + await personal("u-1"); + api.audit.fail = true; + const res = await api.op("getMe", { user: "u-1" }); + expect(res.status).toBe(503); + expect((await res.json()).error.code).toBe("audit_unavailable"); + }); +}); + +describe("書き換えと削除の経路が無い(受け入れ条件 21、I8)", () => { + it("保存の実装は追記の関数だけを持つ", () => { + for (const cls of [MemoryAuditLog, DynamoAuditLog, StructuredLogAudit]) { + expect(Object.getOwnPropertyNames(cls.prototype).sort(), cls.name).toEqual(["append", "constructor"]); + } + }); +}); + +describe("秘密と個人情報を書かない(受け入れ条件 22・23、I9)", () => { + it("キー・ロールの ARN・ログインの token・委任の値・GitHub の code と proof・名前を含む要求でも、記録に値が入らない", async () => { + const acc = await personal("u-1"); + const login = await api.tokens.bearer("u-1"); + const secrets = [ + "sk-ant-SECRET-0001", + "arn:aws:iam::123456789012:role/secret-role", + login.slice("Bearer ".length), + "gh-code-SECRET", + "gh-state-SECRET", + "proof-SECRET", + "秘密のチーム", + "秘密のクライアント", + ]; + await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "anthropic_api_key", api_key: secrets[0] }] }); + await api.op("putModelCredential", { user: "u-1", accountId: acc, items: [{ kind: "aws_role", role_arn: secrets[1], region: "us-east-1" }] }); + await api.op("postRepositoryCandidates", { user: "u-1", accountId: acc, items: [{ code: secrets[3], state: secrets[4] }] }); + await api.op("registerRepository", { user: "u-1", accountId: acc, items: [{ proof: secrets[5], repository_id: 101 }] }); + await api.op("createTeam", { user: "u-1", items: [{ name: secrets[6], seat_type: "standard", idempotency_key: "t" }] }); + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: secrets[7], scope: "read", expires_in_seconds: 600, idempotency_key: "c" }] }), + ); + await api.op("getMe", { authorization: `Bearer ${issued.body.credential}` }); + const text = JSON.stringify(api.audit.items); + for (const s of [...secrets, issued.body.credential]) expect(text, s).not.toContain(s); + }); + + it("記録の列は固定の一覧だけで、メールアドレス・氏名・課題の題と本文を写す列を持たない", async () => { + await personal("u-1"); + for (const item of api.audit.items) expect(Object.keys(item).sort()).toEqual([...AUDIT_COLUMNS].sort()); + }); +}); diff --git a/factory/api/src/audit/dynamo-log.ts b/factory/api/src/audit/dynamo-log.ts new file mode 100644 index 000000000..00ccee2cd --- /dev/null +++ b/factory/api/src/audit/dynamo-log.ts @@ -0,0 +1,19 @@ +// 操作の記録の表(#2384 の「データ構造」)。区分の鍵は record_id だけで、条件つきの追加だけを行う(I8) +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class DynamoAuditLog implements AuditLog { + private readonly doc: DynamoDBDocumentClient; + + constructor( + private readonly tableName: string, + client: DynamoDBClient = new DynamoDBClient({}), + ) { + this.doc = DynamoDBDocumentClient.from(client, { marshallOptions: { removeUndefinedValues: true } }); + } + + async append(record: AuditRecord): Promise { + await this.doc.send(new PutCommand({ TableName: this.tableName, Item: toItem(record), ConditionExpression: "attribute_not_exists(record_id)" })); + } +} diff --git a/factory/api/src/audit/log.ts b/factory/api/src/audit/log.ts new file mode 100644 index 000000000..aebf6efc5 --- /dev/null +++ b/factory/api/src/audit/log.ts @@ -0,0 +1,76 @@ +// 操作の記録(#2384 の集約「操作の記録」・不変条件 I7〜I9)。利用者向けの操作 1 回に 1 件を追記する。 +// 保存の契約は追記の関数 1 つだけを持ち、書き換えと削除の関数を持たない(I8)。 +// 記録は要求の本文を写さず、許可した鍵の値と固定の列だけで組む(決定 12・I9) +import { AUDIT_KEYS } from "@ndf-factory/contracts/operations"; +import type { Via } from "../accounts/model.ts"; + +export type AuditOutcome = "ok" | "partial" | "rejected"; + +export interface AuditRecord { + // 要求の識別子(API Gateway の要求の ID、無ければ UUID)。構造化ログの同じ要求の行とたどれる + recordId: string; + // 受けた日時(UTC、ミリ秒まで) + at: string; + // 空は 401 で利用者を決められなかった + userId: string | null; + // 空は人の直の操作か、401 で決められなかった + credentialId: string | null; + via: Via; + // 空は契約に無い経路 + operationId: string | null; + accountId: string | null; + // <鍵の名前>=<値>。許可した鍵だけ + targetIds: string[]; + outcome: AuditOutcome; + status: number; + // rejected の誤りの code。partial は失敗した要素の code を重ねずに , で並べる。ok は空 + reason: string | null; +} + +export interface AuditLog { + append(record: AuditRecord): Promise; +} + +// 記録の列の名前(表の列と同じ)。この一覧の外の列を持たない +export const AUDIT_COLUMNS = [ + "record_id", + "at", + "user_id", + "credential_id", + "via", + "operation_id", + "account_id", + "target_ids", + "outcome", + "status", + "reason", +] as const; + +const ALLOWED: ReadonlySet = new Set(AUDIT_KEYS); + +// 許可した鍵の値だけを残す(鍵の名前が一覧に無い値・形の違う値は捨てる) +export function allowedTargets(targets: readonly string[]): string[] { + const out: string[] = []; + for (const t of targets) { + const i = t.indexOf("="); + if (i < 1 || !ALLOWED.has(t.slice(0, i))) continue; + if (!out.includes(t)) out.push(t); + } + return out; +} + +export function toItem(r: AuditRecord): Record<(typeof AUDIT_COLUMNS)[number], unknown> { + return { + record_id: r.recordId, + at: r.at, + user_id: r.userId, + credential_id: r.credentialId, + via: r.via, + operation_id: r.operationId, + account_id: r.accountId, + target_ids: allowedTargets(r.targetIds), + outcome: r.outcome, + status: r.status, + reason: r.reason, + }; +} diff --git a/factory/api/src/audit/memory-log.ts b/factory/api/src/audit/memory-log.ts new file mode 100644 index 000000000..1d33543d0 --- /dev/null +++ b/factory/api/src/audit/memory-log.ts @@ -0,0 +1,14 @@ +// DynamoAuditLog と同じく追記だけを行う、メモリの上の記録。テストと手元の確かめに使う +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class MemoryAuditLog implements AuditLog { + readonly items: ReturnType[] = []; + // 真なら追記に失敗する(503 の確かめ) + fail = false; + + async append(record: AuditRecord): Promise { + if (this.fail) throw new Error("audit down"); + if (this.items.some((i) => i.record_id === record.recordId)) throw new Error("record exists"); + this.items.push(toItem(record)); + } +} diff --git a/factory/api/src/audit/middleware.ts b/factory/api/src/audit/middleware.ts new file mode 100644 index 000000000..ab9e478ff --- /dev/null +++ b/factory/api/src/audit/middleware.ts @@ -0,0 +1,87 @@ +// 操作の記録の書き手(#2384 の F6)。利用者向けの要求 1 回ごとに、結果(成功・一部の失敗・400・401・403・404)によらず +// 記録を 1 件追記する。追記に失敗したら記録の全体を構造化ログへ出し、応答を 503 audit_unavailable にする(決定 11・I7) +import { randomUUID } from "node:crypto"; +import type { Context, MiddlewareHandler } from "hono"; +import { OPERATIONS, findOperation, type Operation } from "@ndf-factory/contracts/operations"; +import { isMcpInternal, type AuthEnv } from "../auth/resolve.ts"; +import { ServiceUnavailableError } from "../errors.ts"; +import { toItem, type AuditLog, type AuditOutcome, type AuditRecord } from "./log.ts"; + +// MCP の受け口そのものへの要求は操作ではないため記録しない(内部の要求をそれぞれ記録する) +export const MCP_PATH = "/mcp"; + +const PATTERNS = OPERATIONS.map((op) => ({ + op: op as Operation, + re: new RegExp(`^${op.path.replace("{accountId}", "([^/]+)")}$`), +})); + +// 操作の登録の層を通らなかった要求(401 など)の操作を、メソッドと経路から引く +function match(method: string, path: string): { op: Operation; accountId: string | null } | null { + for (const { op, re } of PATTERNS) { + if (op.method !== method) continue; + const m = re.exec(path); + if (m) return { op, accountId: m[1] ? decodeURIComponent(m[1]) : null }; + } + return null; +} + +// API Gateway の要求の ID(Lambda の環境)。無ければ UUID +export function requestIdOf(c: Context): string { + const env = c.env as { event?: { requestContext?: { requestId?: unknown } } } | undefined; + const id = env?.event?.requestContext?.requestId; + return typeof id === "string" && id !== "" ? id : randomUUID(); +} + +async function recordOf(c: Context, at: string): Promise { + const caller = c.get("caller"); + const trace = c.get("operation"); + // 呼び出し元の種類は資格情報の種類と内部の印だけで決め、外から届くほかのヘッダーを見ない(I16) + const delegated = (c.req.header("Authorization") ?? "").startsWith("Bearer ndfd_"); + const via = caller?.via ?? (isMcpInternal(c.env) ? "mcp" : delegated ? "api" : "screen"); + const matched = trace ? { op: findOperation(trace.operationId), accountId: trace.accountId } : match(c.req.method, c.req.path); + const status = c.res.status; + let outcome: AuditOutcome = "ok"; + let reason: string | null = null; + if (status !== 200) { + outcome = "rejected"; + const body = (await c.res + .clone() + .json() + .catch(() => null)) as { error?: { code?: unknown } } | null; + reason = typeof body?.error?.code === "string" ? body.error.code : null; + } else if (trace && trace.reasons.length > 0) { + outcome = "partial"; + reason = trace.reasons.join(","); + } + return { + recordId: c.get("requestId") ?? requestIdOf(c), + at, + userId: caller?.userId ?? null, + credentialId: caller?.delegation?.credentialId ?? null, + via, + operationId: matched?.op?.operationId ?? null, + accountId: matched?.accountId ?? null, + targetIds: trace?.targetIds ?? [], + outcome, + status, + reason, + }; +} + +export function recordOperations(log: () => AuditLog | null | undefined, fallback: (line: Record) => void): MiddlewareHandler { + return async (c, next) => { + const at = new Date().toISOString(); + await next(); + if (c.req.method === "OPTIONS" || c.req.path === MCP_PATH) return; + const writer = log(); + if (!writer) return; + const record = await recordOf(c, at); + try { + await writer.append(record); + } catch { + fallback({ audit_fallback: true, record: toItem(record) }); + const err = new ServiceUnavailableError("audit_unavailable"); + c.res = new Response(JSON.stringify(err.body()), { status: 503, headers: { "Content-Type": "application/json" } }); + } + }; +} diff --git a/factory/api/src/audit/structured-log.ts b/factory/api/src/audit/structured-log.ts new file mode 100644 index 000000000..f17970689 --- /dev/null +++ b/factory/api/src/audit/structured-log.ts @@ -0,0 +1,11 @@ +// 操作の記録の表が無い間(インフラが AUDIT_TABLE_NAME を渡す前)の書き手。記録を構造化ログへ 1 行ずつ出し、 +// 後から表へ入れ直せるようにする +import { toItem, type AuditLog, type AuditRecord } from "./log.ts"; + +export class StructuredLogAudit implements AuditLog { + constructor(private readonly write: (line: string) => void = (line) => console.log(line)) {} + + async append(record: AuditRecord): Promise { + this.write(JSON.stringify({ audit: toItem(record) })); + } +} diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts index f374eda8a..3d19932cf 100644 --- a/factory/api/src/auth/resolve.ts +++ b/factory/api/src/auth/resolve.ts @@ -1,6 +1,7 @@ import type { Context, MiddlewareHandler } from "hono"; import type { AccountService } from "../accounts/service.ts"; import type { AccountContext, Caller } from "../accounts/model.ts"; +import type { AuditLog } from "../audit/log.ts"; import type { DelegationService } from "../delegations/service.ts"; import { AuthError } from "../errors.ts"; import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts"; @@ -10,6 +11,8 @@ export interface AuthDeps { accounts: AccountService; // 委任の資格情報の照合(#2384)。無ければ委任の資格情報は 401 delegations?: DelegationService | null; + // 操作の記録の書き手(#2384)。無ければ記録しない(テストで記録を見ないとき) + audit?: AuditLog | null; } // MCP の受け口が内部の要求に付ける印(app.request の第 3 引数の環境に置く)。外から届くヘッダーでは決めない(決定 13・I16) @@ -47,7 +50,7 @@ export interface OperationTrace { reasons: string[]; } -export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace } }; +export type AuthEnv = { Variables: { caller: Caller; auth: AuthDeps; operation?: OperationTrace; requestId?: string } }; // 経路の前に token を毎回検証し、利用者を文脈に置く中間処理(E3)。 // #2171・#2186・#2174 の経路も同じ中間処理を通す diff --git a/factory/api/src/auth/verify.test.ts b/factory/api/src/auth/verify.test.ts index 41fd4293b..33706d1ec 100644 --- a/factory/api/src/auth/verify.test.ts +++ b/factory/api/src/auth/verify.test.ts @@ -45,6 +45,6 @@ describe("token の検証(受け入れ条件 4)", () => { await api.call("GET", "/me", { authorization: auth }); const text = JSON.stringify(api.logs); expect(text).not.toContain(auth.slice("Bearer ".length)); - expect(api.logs.at(-1)).toEqual({ user_id: "u-3", method: "GET", path: "/me", status: 200 }); + expect(api.logs.at(-1)).toEqual({ request_id: expect.any(String), user_id: "u-3", method: "GET", path: "/me", status: 200 }); }); }); diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index d4c73c04c..f4f1781eb 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -12,10 +12,15 @@ 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 { registerFactory, type FactoryDeps } from "../routes/factory.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; export interface LogEntry { + // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) + request_id: string; user_id: string | null; method: string; path: string; @@ -47,8 +52,10 @@ export function createApiApp( ): Hono { const app = new Hono(); app.use("*", async (c, next) => { + c.set("requestId", requestIdOf(c)); await next(); log({ + request_id: c.get("requestId")!, user_id: c.get("caller")?.userId ?? null, method: c.req.method, path: c.req.path, @@ -64,6 +71,8 @@ export function createApiApp( allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"], }), ); + // 操作の記録は認証より外に置き、401 で断った要求も記録する(I7) + app.use("*", recordOperations(() => deps().audit, (line) => console.log(JSON.stringify(line)))); app.use("*", requireCaller(deps)); app.onError((err, c) => { const known = err instanceof ApiError ? err : new AccountUnavailableError(); @@ -95,6 +104,8 @@ export function depsFromEnv(): AuthDeps { 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/testing/app.ts b/factory/api/src/testing/app.ts index d093aa207..292ba14be 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -2,6 +2,7 @@ import { operation, operationUrl, type OperationId } from "@ndf-factory/contract import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { MemoryDelegationStore } from "../delegations/memory-store.ts"; +import { MemoryAuditLog } from "../audit/memory-log.ts"; import { DelegationService } from "../delegations/service.ts"; import { createApiApp, type LogEntry } from "../handlers/api.ts"; import { createTokenKit, type TokenKit } from "./tokens.ts"; @@ -61,6 +62,7 @@ export interface TestApi { store: MemoryAccountStore; service: AccountService; delegationStore: MemoryDelegationStore; + audit: MemoryAuditLog; delegations: DelegationService; // 時刻を進められる時計(委任の期限の確かめ) clock: { now: Date }; @@ -81,6 +83,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise clock.now }); const secrets = new FakeSecrets(); const h = opts.factory ? await createRunsHarness() : null; @@ -100,7 +103,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise ({ verifier: tokens.verifier, accounts: service, delegations }), + () => ({ verifier: tokens.verifier, accounts: service, delegations, audit }), (e) => logs.push(e), () => null, factory && (() => factory!), @@ -110,6 +113,7 @@ export async function createTestApi(opts: { factory?: boolean } = {}): Promise Date: Wed, 4 Nov 2026 22:46:38 +0000 Subject: [PATCH 18/39] =?UTF-8?q?MCP=20=E3=81=AE=E5=8F=97=E3=81=91?= =?UTF-8?q?=E5=8F=A3=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81=E3=83=84=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=91=BC=E3=81=B3=E5=87=BA=E3=81=97=E3=82=92?= =?UTF-8?q?=E5=90=8C=E3=81=98=E3=82=A2=E3=83=97=E3=83=AA=E3=81=AE=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=B8=E9=80=81=E3=82=8B=EF=BC=88#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /mcp で MCP の要求を状態を持たずに受け(@modelcontextprotocol/server 2.4 の Web 標準の受け口)、 操作の表から作ったツールの呼び出しを、委任の資格情報を付けたまま同じアプリへ内部の要求として送る。 認証・範囲の照合・要素の検査・認可・記録は API と同じ処理を通り、記録の呼び出し元の種類は mcp になる。 受け口は委任の資格情報だけを受け、画面と違うオリジンは 403、GET と DELETE は 405 にする。 Co-Authored-By: Claude Opus 5.5 --- factory/api/package.json | 1 + factory/api/src/handlers/api.ts | 3 + factory/api/src/mcp/mcp.test.ts | 116 ++++++++++++++++++++++++++++++++ factory/api/src/mcp/server.ts | 93 +++++++++++++++++++++++++ factory/package-lock.json | 35 ++++++++++ 5 files changed, 248 insertions(+) create mode 100644 factory/api/src/mcp/mcp.test.ts create mode 100644 factory/api/src/mcp/server.ts diff --git a/factory/api/package.json b/factory/api/package.json index dda05bd68..38c107d07 100644 --- a/factory/api/package.json +++ b/factory/api/package.json @@ -17,6 +17,7 @@ "@aws-sdk/lib-dynamodb": "^3.1160.0", "@aws-sdk/s3-request-presigner": "^3.1161.0", "@aws-sdk/util-dynamodb": "^3.996.9", + "@modelcontextprotocol/server": "^2.4.0", "@ndf-factory/contracts": "*", "aws-jwt-verify": "^5.2.1", "hono": "^4.13.14", diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index f4f1781eb..d6b306326 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -15,6 +15,7 @@ import { DynamoDelegationStore } from "../delegations/store.ts"; import { recordOperations, requestIdOf } from "../audit/middleware.ts"; import { DynamoAuditLog } from "../audit/dynamo-log.ts"; import { StructuredLogAudit } from "../audit/structured-log.ts"; +import { registerMcp } from "../mcp/server.ts"; import { registerFactory, type FactoryDeps } from "../routes/factory.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; @@ -83,6 +84,8 @@ export function createApiApp( 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/mcp/mcp.test.ts b/factory/api/src/mcp/mcp.test.ts new file mode 100644 index 000000000..afa9e92c4 --- /dev/null +++ b/factory/api/src/mcp/mcp.test.ts @@ -0,0 +1,116 @@ +// MCP の受け口(#2384 の受け入れ条件 2・3・17・18、不変条件 I11・I15・I16) +import { Hono } from "hono"; +import { beforeEach, describe, expect, it } from "vitest"; +import { OPERATIONS } from "@ndf-factory/contracts/operations"; +import type { AuthEnv } from "../auth/resolve.ts"; +import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; +import { registerMcp, tools, type AppRequest } from "./server.ts"; + +let api: TestApi; +let credential: string; +let account: string; +let id = 0; + +beforeEach(async () => { + api = await createTestApi({ factory: true }); + account = (await (await api.op("getMe", { user: "u-1" })).json())[0].personal_account_id; + const issued = await firstResult( + await api.op("createDelegation", { user: "u-1", items: [{ client_name: "c", scope: "read_write", expires_in_seconds: 3600, idempotency_key: "m" }] }), + ); + credential = issued.body.credential; +}); + +async function rpc(method: string, params: unknown, authorization = `Bearer ${credential}`, headers: Record = {}) { + const res = await api.app.request("/mcp", { + method: "POST", + headers: { Authorization: authorization, "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }), + }); + return { status: res.status, body: (await res.json().catch(() => null)) as any }; +} + +const callTool = async (name: string, args: Record) => (await rpc("tools/call", { name, arguments: args })).body.result; + +describe("ツールと契約の対応(受け入れ条件 2、I11)", () => { + it("tools/list のツールの名前の集合が、契約の利用者向けの操作と一致する", async () => { + const { status, body } = await rpc("tools/list", {}); + expect(status).toBe(200); + expect(body.result.tools.map((t: { name: string }) => t.name).sort()).toEqual(OPERATIONS.map((o) => o.operationId).sort()); + }); + + it("人だけが確定できるツールは説明の頭で断られることを示し、入力は操作の形を持つ", () => { + const byName = new Map(tools().map((t) => [t.name, t])); + expect(byName.get("answerLoopPending")!.description).toMatch(/^人だけが確定できる/); + expect(byName.get("startSprintRun")!.inputSchema.required).toEqual(["account_id", "items"]); + expect(byName.get("getSprintRunDetail")!.inputSchema.properties.sprint_run_id).toMatchObject({ type: "array", maxItems: 100 }); + expect(byName.get("getMe")!.inputSchema.required).toEqual([]); + }); +}); + +describe("ツールの呼び出し(受け入れ条件 3・17・18、I15・I16)", () => { + it("API の経路と同じ結果を返し、操作の記録に mcp として残る。tools/list は記録しない", async () => { + const direct = await (await api.op("listSprintRuns", { authorization: `Bearer ${credential}`, accountId: account })).json(); + const before = api.audit.items.length; + await rpc("tools/list", {}); + expect(api.audit.items.length).toBe(before); + const r = await callTool("listSprintRuns", { account_id: account }); + expect(r.structuredContent).toEqual({ status: 200, body: direct }); + expect(r.isError).toBe(false); + expect(JSON.parse(r.content[0].text)).toEqual(r.structuredContent); + expect(api.audit.items.at(-1)).toMatchObject({ operation_id: "listSprintRuns", via: "mcp", user_id: "u-1", outcome: "ok" }); + expect(api.audit.items.length).toBe(before + 1); + }); + + it("人だけが確定できるツールは 403 human_only を返し、答えも状態も保存しない", async () => { + const r = await callTool("answerLoopPending", { account_id: account, items: [{ loop_id: "l", issue: 1, decision: "approved" }] }); + expect(r.isError).toBe(true); + expect(r.structuredContent).toMatchObject({ status: 403, body: { error: { code: "human_only" } } }); + expect(api.h!.table.loops.size).toBe(0); + }); + + it("承認の依頼の材料(スプリントの詳細)は読める。要素の結果はそのまま返る", async () => { + const r = await callTool("getSprintRunDetail", { account_id: account, sprint_run_id: ["sr-none"] }); + expect(r.structuredContent.status).toBe(200); + expect(r.structuredContent.body[0]).toMatchObject({ ok: false, error: { code: "forbidden" } }); + }); + + it("アカウントが要るツールに account_id が無ければ 400 で、アプリへ送らない", async () => { + const r = await callTool("listSprintRuns", {}); + expect(r.structuredContent.status).toBe(400); + }); + + it("受け口が受け取る依存はアプリへの要求の関数だけで、ツールの呼び出しはすべてそこを通る", async () => { + const seen: string[] = []; + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("caller", { userId: "u", idp: "google", delegation: { credentialId: "x", scope: "read" }, via: "api" }); + await next(); + }); + const call: AppRequest = async (path, init) => { + seen.push(`${init.method} ${path}`); + return Response.json([]); + }; + registerMcp(app, call, () => null); + const res = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer ndfd_x", "Content-Type": "application/json", Accept: "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "listLoops", arguments: { account_id: "a", repository_id: 1 } } }), + }); + expect(res.status).toBe(200); + expect(seen).toEqual(["GET /accounts/a/loops?repository_id=1"]); + }); +}); + +describe("受け口の資格情報(決定 7)", () => { + it("Cognito の token は 401、資格情報が無ければ 401", async () => { + expect((await rpc("tools/list", {}, await api.tokens.bearer("u-1"))).status).toBe(401); + expect((await rpc("tools/list", {}, "")).status).toBe(401); + }); + + it("画面と違うオリジンからの要求は 403、GET と DELETE は 405", async () => { + expect((await rpc("tools/list", {}, `Bearer ${credential}`, { Origin: "https://evil.example.com" })).status).toBe(403); + for (const method of ["GET", "DELETE"]) { + expect((await api.app.request("/mcp", { method, headers: { Authorization: `Bearer ${credential}` } })).status).toBe(405); + } + }); +}); diff --git a/factory/api/src/mcp/server.ts b/factory/api/src/mcp/server.ts new file mode 100644 index 000000000..40c102131 --- /dev/null +++ b/factory/api/src/mcp/server.ts @@ -0,0 +1,93 @@ +// MCP の受け口(#2384 の F4・決定 6・7・14)。POST /mcp で MCP の要求を受け、操作の表から作ったツールの呼び出しを、 +// 同じアプリの経路へ内部の要求として送る。認証・範囲の照合・要素の検査・認可・記録は画面と API と同じ処理を通る(I15)。 +// 状態を持たず、要求ごとに JSON の 1 応答で返す。委任の資格情報だけを受ける +import type { Hono } from "hono"; +import { Server, WebStandardStreamableHTTPServerTransport, type Tool } from "@modelcontextprotocol/server"; +import { MAX_ITEMS, OPERATIONS, findOperation, operationUrl, type Operation } from "@ndf-factory/contracts/operations"; +import { MCP_INTERNAL, type AuthEnv } from "../auth/resolve.ts"; +import { AuthError, ForbiddenError } from "../errors.ts"; +import { MCP_PATH } from "../audit/middleware.ts"; + +// アプリへ内部の要求を送る関数(Hono の app.request)。MCP の受け口が受け取る依存はこれだけ +export type AppRequest = (path: string, init: RequestInit, env: Record) => Response | Promise; + +export const SERVER_INFO = { name: "ndf-factory", version: "0.1.0" } as const; + +const HUMAN_NOTE = "人だけが確定できる。MCP からは常に断られる。画面で行う。"; + +const BODY_KINDS = new Set(["create", "update", "action"]); + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { type: "object"; properties: Record; required: string[] }; +} + +// ツールの定義を操作の表から作る。手で書いたツールの定義を持たない(契約の表の「MCP のツール」) +export function toolOf(op: Operation): ToolDefinition { + const properties: Record = {}; + const required: string[] = []; + if (op.path.includes("{accountId}")) { + properties.account_id = { type: "string", description: "アカウントの ID" }; + required.push("account_id"); + } + if (BODY_KINDS.has(op.kind)) { + properties.items = { type: "array", minItems: 1, maxItems: MAX_ITEMS, items: op.itemSchema ?? { type: "object" }, description: "要素の配列。結果は同じ順に返る" }; + required.push("items"); + } else { + for (const p of op.parameters) { + properties[p.name] = { ...(p.schema as object), ...(p.description ? { description: p.description } : {}) }; + if (p.required) required.push(p.name); + } + } + const text = [op.summary, op.description].filter((s) => s !== "").join("\n"); + return { name: op.operationId, description: op.access === "human" ? `${HUMAN_NOTE}\n${text}` : text, inputSchema: { type: "object", properties, required } }; +} + +export const tools = (): ToolDefinition[] => OPERATIONS.map((op) => toolOf(op as Operation)); + +function result(status: number, body: unknown) { + const value = { status, body }; + return { content: [{ type: "text" as const, text: JSON.stringify(value) }], structuredContent: value, isError: status >= 400 }; +} + +// ツールの入力から経路・query・本文を組み、Authorization をそのまま付けて内部の要求として送る +async function callTool(call: AppRequest, authorization: string, name: string, args: Record) { + const op = findOperation(name); + if (!op) return result(400, { error: { code: "unknown_tool", message: `${name} というツールはありません` } }); + const accountId = typeof args.account_id === "string" ? args.account_id : null; + if (op.path.includes("{accountId}") && accountId === null) return result(400, { error: { code: "invalid_request", message: "account_id が要ります" } }); + const query: Record = {}; + if (!BODY_KINDS.has(op.kind)) for (const p of op.parameters) if (args[p.name] !== undefined) query[p.name] = args[p.name]; + const hasBody = BODY_KINDS.has(op.kind); + const res = await call( + operationUrl(op, accountId, query), + { + method: op.method, + headers: { Authorization: authorization, ...(hasBody ? { "Content-Type": "application/json" } : {}) }, + body: hasBody ? JSON.stringify(args.items ?? null) : undefined, + }, + { [MCP_INTERNAL]: true }, + ); + return result(res.status, await res.json().catch(() => null)); +} + +export function registerMcp(app: Hono, call: AppRequest, webOrigin: () => string | null): void { + app.post(MCP_PATH, async (c) => { + // 委任の資格情報だけを受ける(Cognito の token は 401) + if (!c.get("caller").delegation) throw new AuthError(); + // ブラウザからの要求は画面のオリジンだけを受ける(Streamable HTTP の Origin の検査) + const origin = c.req.header("Origin"); + if (origin !== undefined && origin !== webOrigin()) throw new ForbiddenError(); + const authorization = c.req.header("Authorization")!; + const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); + // 定義は契約から作った JSON Schema(JSON の値だけを持つ) + server.setRequestHandler("tools/list", async () => ({ tools: tools() as unknown as Tool[] })); + server.setRequestHandler("tools/call", async (req) => callTool(call, authorization, req.params.name, (req.params.arguments ?? {}) as Record)); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(transport); + return transport.handleRequest(c.req.raw); + }); + // 状態を持たないため、GET のストリームとセッションの終わりは受けない + for (const method of ["get", "delete"] as const) app[method](MCP_PATH, (c) => c.body(null, 405)); +} diff --git a/factory/package-lock.json b/factory/package-lock.json index 001c792b7..43c648eb2 100644 --- a/factory/package-lock.json +++ b/factory/package-lock.json @@ -29,6 +29,7 @@ "@aws-sdk/lib-dynamodb": "^3.1160.0", "@aws-sdk/s3-request-presigner": "^3.1161.0", "@aws-sdk/util-dynamodb": "^3.996.9", + "@modelcontextprotocol/server": "^2.4.0", "@ndf-factory/contracts": "*", "aws-jwt-verify": "^5.2.1", "hono": "^4.13.14", @@ -1063,6 +1064,31 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.4.0.tgz", + "integrity": "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.4.0.tgz", + "integrity": "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.4.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@ndf-factory/api": { "resolved": "api", "link": true @@ -7455,6 +7481,15 @@ "node": ">=12" } }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "runner": { "name": "@ndf-factory/runner", "dependencies": { From 5bbce773ee8954d87bfc4557e16487d8a52f825a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 23:06:38 +0000 Subject: [PATCH 19/39] =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=AE=E7=94=BB=E9=9D=A2=E5=90=91=E3=81=91=E3=81=AE=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=82=92=E3=80=81=E8=A6=81=E7=B4=A0=E3=81=AE=E9=85=8D?= =?UTF-8?q?=E5=88=97=E3=81=A7=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=99=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E3=81=AE=E5=BD=A2=E3=81=B8=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf-factory-accounts-and-plans.md | 24 +++++++------- ...df-factory-dev-loops-data-and-contracts.md | 22 ++++++------- ...df-factory-organizer-data-and-contracts.md | 10 +++--- .../ndf-factory-runs-and-runner.md | 32 +++++++++---------- .../ndf-factory-service-boundary.md | 16 ++++++++++ .../ndf-factory-sprint-views.md | 14 ++++---- .../ndf-factory-ui-components.md | 4 +-- 7 files changed, 71 insertions(+), 51 deletions(-) diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md index 9f5f5006f..055d62680 100644 --- a/docs/specifications/ndf-factory-accounts-and-plans.md +++ b/docs/specifications/ndf-factory-accounts-and-plans.md @@ -108,7 +108,7 @@ graph TD あわせて `LIMITS_TABLE`・`AccountService`・エラーの型(`ApiError`・`AuthError`・`ForbiddenError`・`PlanError`)と、`AccountContext`・`Caller`・`ExecutionMode`・`Plan`・`SeatType`・`Limits`・`Judgment`・`CurrentUsage`・`LimitReason`・`AuthEnv`・`ApiApp` の型を export する。 -**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts/{account_id}` の `limits` で上限を出す。 +**使える量は HTTP の経路にしない。** #2186・#2174 の処理は同じ `factory/api/` の中で上の関数を呼ぶ。画面は `GET /accounts?account_id=` の `limits` で上限を出す。 ### token の検証とアカウントの解決 @@ -214,22 +214,22 @@ sequenceDiagram ### API の経路 -経路はすべて `Authorization: Bearer ` を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。 +経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。 -| 経路 | 入力 | 成功 | 失敗 | +| 経路 | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | -| `GET /me` | — | 200 `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` | -| `GET /accounts/{account_id}` | — | 200 `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` | -| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type}` | 201 アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` | -| `PUT /accounts/{account_id}/execution-mode` | `{execution_mode: "local" \| "byok" \| "managed"}` | 200 アカウントの本文。今と同じ値なら履歴の行を足さずに 200 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | -| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 201 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` | -| `PATCH /accounts/{account_id}/seats/{user_id}` | `{seat_type}` | 200 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 | -| `DELETE /accounts/{account_id}/seats/{user_id}` | — | 204 | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 | +| `GET /me` | — | `{user_id, idp, personal_account_id, accounts: [{account_id, kind, name, plan, role, seat_type, execution_mode}]}`。先頭は個人のアカウント。初回は個人のアカウントを作る | 401・503 `account_unavailable` | +| `GET /accounts?account_id=` | 鍵 `account_id`(要素ごとにアカウントを解決する) | `{account_id, kind, name, plan, role, seat_type, execution_mode, limits: {concurrent_sprint_runs, monthly_tokens}, seats}`。`monthly_tokens` は `managed` のときだけ数で、ほかは `null`。`seats` はチームだけで `[{user_id, idp, seat_type, added_at}]`、個人では `null` | 401・403 `forbidden` | +| `POST /teams` | `{name: 1〜64 字(空白だけは不可), seat_type, idempotency_key}` | アカウントの本文(上と同じ形)。作った利用者が持ち主で、その席が 1 つある | 400 `invalid_request` | +| `PATCH /accounts` | `{account_id, execution_mode: "local" \| "byok" \| "managed"}`(要素ごとにアカウントを解決する) | アカウントの本文。今と同じ値なら履歴の行を足さずに成功 | 400 `invalid_request`・403 `forbidden`(I22・I18)・409 `managed_not_allowed_on_free`(`message` は「Free の料金プランでは managed(サービスの Bedrock)を選べません」)・503 `account_unavailable` | +| `POST /accounts/{account_id}/seats` | `{user_id: 1〜128 字, seat_type}` | 席 `{user_id, idp, seat_type, added_at}` | 400・403・409 `not_a_team`・422 `user_not_found`・409 `seat_exists` | +| `PATCH /accounts/{account_id}/seats` | `{user_id, seat_type}` | 席 | 400・403・409 `not_a_team`・404 `seat_not_found`・503 | +| `DELETE /accounts/{account_id}/seats?user_id=` | 鍵 `user_id` | `{removed: true}` | 403・409 `not_a_team`・409 `owner_seat_protected`・404 `seat_not_found`・503 | - **持ち主の確かめは、席の有無と料金プランの照合より先に行う。** 404 `seat_not_found` と 409 `managed_not_allowed_on_free` は、要求した者が持ち主と確かめた後だけ返す - **`byok` を選んでもキーの登録はこの経路で求めない。** キーの保存は #2186 が持つ - **`GET /me` のチームの一覧は GSI を候補として引き、元の席を強い整合で確かめる。** 外した席が GSI に残る間に、そのチームの名前や料金プランを返さないためである -- 本文が JSON のオブジェクトでなければ 400 `invalid_request`。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える +- 本文が配列でなければ要求全体を、要素がオブジェクトでなければその要素を 400 `invalid_request` にする。経路の中で起きた想定外の失敗は 503 `account_unavailable` に揃える ### GitHub の仲立ち @@ -434,7 +434,7 @@ graph TD | 他人のアカウント(I18) | 利用者 A の token で、他人の個人のアカウント・入っていないチーム・存在しない ID の 3 つに読む・書くを送ると、すべて 403 で本文が同じであること | | 料金プランの組(I13) | 個人のアカウントへの席の操作は 409、`setPlan` で個人に `team`・チームに `pro` は失敗で、行が変わらないこと | | 履歴(I20) | 席・料金プラン・実行の形態の変更のたびに履歴の行が 1 つ増え、変更が失敗したときは増えないこと | -| 実行の形態の読み出し | `GET /me`・`GET /accounts/{account_id}` の応答と `resolveAccount` の文脈に保存した形態が出ること | +| 実行の形態の読み出し | `GET /me`・`GET /accounts?account_id=` の応答と `resolveAccount` の文脈に保存した形態が出ること | | 実行の形態の変更(I21・I22) | 持ち主の変更は 200 で形態が変わり、持ち主でない者は 403、Free で `managed` は 409 `managed_not_allowed_on_free` で、どちらも形態と履歴が変わらないこと | | Free へ下げる(I21) | `managed` のプロのアカウントへ `setPlan(…, "free")` を呼ぶと、料金プランが `free`・形態が `local` になり、2 つの履歴の行が同じトランザクションで増えること。形態の書き込みが失敗すれば料金プランも変わらないこと | | 版の競合(I21) | `setPlan` の書き込みの前に別の要求が `managed` を保存して版を進めても、`setPlan` が読み直して、終わったとき料金プランが `free`・形態が `local` であること | diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md index 41e3f8e0b..70dc77a8e 100644 --- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md +++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md @@ -150,19 +150,19 @@ erDiagram ### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) -どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。ループの経路はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found`。エラーの本文は `{"error": {"code", "message", "details"?}}`。 +どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 -| 経路 | 誰が | 入力 | 成功 | 失敗 | +| 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | -| `POST /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | `{name, milestones, budget?: {laps?, usd?, hours?}}` | 201 `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | -| `GET /accounts/{a}/repositories/{r}/loops` | 席を持つ人 | — | 200 `{loops: [{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}]}`(新しい順 50 件) | 400・403 | -| `GET /accounts/{a}/loops/{id}` | 席を持つ人 | — | 200 `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | -| `PATCH /accounts/{a}/loops/{id}` | 席を持つ人 | `{budget}` | 200 ループの 1 行 | 400・403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | -| `POST /accounts/{a}/loops/{id}/next-lap` | 席を持つ人 | — | 202 `{state}` | 403・404・409 `not_awaiting_user_start` | -| `POST /accounts/{a}/loops/{id}/answers` | 席を持つ人 | `{issue, decision: approved \| rejected, pr?}` | 200 `{pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | -| `PATCH /accounts/{a}/repositories/{r}`(`runs.yaml`) | 持ち主 | `{max_concurrent_runs: 1〜100 の整数か null}` | 200 登録の 1 行 | 400・403・404 | -| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | 既存のまま | 既存のまま | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` | +| `POST /accounts/{a}/loops` | 席を持つ人 | `{repository_id, name, milestones, budget?: {laps?, usd?, hours?}, idempotency_key}` | `{loop_id, state}`(`running` か `between_laps`) | 400 `invalid_request`(名前・題の形、1〜10 件、予算が正の数でない)・403・404 `repository_not_found`・409 `milestone_in_use`(`details.milestones`・`details.loop_name`)・409 `loop_name_in_use`(同じ登録の終わりの状態でないループ) | +| `GET /accounts/{a}/loops?repository_id=` | 席を持つ人 | 条件 `repository_id` | `{loop_id, name, milestones, state, wait_reason, laps_closed, cost_usd, budget, budget_remaining, last_run_at, stop_condition, stop_detail}`(新しい順 50 件) | 要求全体の 400・403 | +| `GET /accounts/{a}/loops/detail?loop_id=` | 席を持つ人 | 鍵 `loop_id` | `{loop, laps: [{lap, issues, result, version, cost_usd, seconds}], runs: [{sprint_run_id, lap, state, failure_reason, finish_reason, resume_at, cost_usd, sections}], pending: [{number, reason, lap, answer}]}` | 403・404 | +| `PATCH /accounts/{a}/loops` | 席を持つ人 | `{loop_id, budget}` | ループの 1 行 | 400・403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/cancel` | 席を持つ人 | `{loop_id}` | `{state}`(`cancelled` か、実行の停止を待つ `running`) | 403・404・409 `already_finished` | +| `POST /accounts/{a}/loops/next-lap` | 席を持つ人 | `{loop_id}` | `{state}` | 403・404・409 `not_awaiting_user_start` | +| `POST /accounts/{a}/loops/answers` | 席を持つ人(委任の資格情報では断る) | `{loop_id, issue, decision: approved \| rejected, pr?}` | `{loop_id, pending}` | 400・403・404・409 `not_pending`(`pending` に無い番号) | +| `PATCH /accounts/{a}/repositories`(`runs.yaml`) | 持ち主 | `{repository_id, max_concurrent_runs: 1〜100 の整数か null}` | 登録の 1 行 | 400・403・404 | +| `POST /accounts/{a}/sprint-runs`(`runs.yaml`) | 席を持つ人 | [スプリントの実行の経路](ndf-factory-runs-and-runner.md#画面向けの経路) のまま | 同じ | 既存の失敗に加えて 409 `issue_in_use`(`details.numbers`)・409 `issue_in_loop`(`details.numbers`・`details.loop_name`)・409 `parallel_unsupported_version`、`limits_exceeded` の `reasons` に `concurrent_repo_runs` | ## 関連リンク diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md index d39e7c545..44af6d06b 100644 --- a/docs/specifications/ndf-factory-organizer-data-and-contracts.md +++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md @@ -101,11 +101,13 @@ erDiagram ### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`) -| 経路 | 入力 | 成功 | 失敗の形 | +入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。 + +| 経路 | 要素・条件 | 値 | 失敗の形 | | --- | --- | --- | --- | -| `GET /accounts/{accountId}/repositories/{repositoryId}/approval-requests` | — | 200 `{organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/releases` | `?limit`(1〜100、既定 20) | 200 `{items: [{seq, state, release_run_id, presentations, confirmed_k, result, log}]}`(新しい順) | 400・401・403・404 | -| `POST /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve` | `{choice: "continue" \| "reject", body?}` | 202 `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) | +| `GET /accounts/{accountId}/approval-requests?repository_id=` | 鍵 `repository_id` | `{repository_id, organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 | +| `GET /accounts/{accountId}/releases?repository_id=` | 条件 `repository_id`(1 つ)と `limit`(1〜100、既定 20) | `{seq, state, release_run_id, presentations, confirmed_k, result, log}`(新しい順) | 要求全体の 400・401・403・404 | +| `POST /accounts/{accountId}/releases/resolve` | `{repository_id, seq, choice: "continue" \| "reject", body?}`(委任の資格情報では要求全体を 403 `human_only` で断る) | `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) | 一覧と履歴の経路は読むだけで、承認を確定する経路はこのファイルに無い(I66)。 diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md index 621e8bbf6..a75598c46 100644 --- a/docs/specifications/ndf-factory-runs-and-runner.md +++ b/docs/specifications/ndf-factory-runs-and-runner.md @@ -149,23 +149,23 @@ graph TD ### 画面向けの経路 -機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。 +機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 -| 経路 | 誰が | 入力 | 成功 | 失敗 | +| 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | -| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | 200 `{url}`(`https://github.com/apps//installations/new`) | 403 | -| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | 200 `{url}`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 | -| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り) | 200 `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 | -| `GET /accounts/{a}/repositories` | 席を持つ人 | — | 200 `{repositories: [{repository_id, full_name, installation_id, registered_at, active_sprint_runs}]}` | 403 | -| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 201 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) | -| `DELETE /accounts/{a}/repositories/{repositoryId}` | 持ち主 | — | 204 | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) | -| `GET /accounts/{a}/model-credential` | 持ち主 | — | 200 `{registered, kind, updated_at, external_id}`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 | -| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで) | 200 上と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` | -| `DELETE /accounts/{a}/model-credential` | 持ち主 | — | 204(無くても 204) | 403・503 `credential_unavailable` | -| `POST /accounts/{a}/repositories/{r}/issue-candidates` | 席を持つ人 | `{exclude?: [番号]}`(選び直しで外す番号) | 200 `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` | -| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues}`(1〜20 件の重なりの無い番号) | 201 `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) | -| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | — | 200 `{sprint_runs: [{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]}`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 400(ID の形)・403・503 `progress_unavailable` | -| `POST /accounts/{a}/sprint-runs/{id}/cancel` | 席を持つ人 | — | 200 `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` | +| `GET /accounts/{a}/github/install-url` | 席を持つ人 | — | `[{url}]`(`https://github.com/apps//installations/new`) | 403 | +| `GET /accounts/{a}/github/authorize-url` | 持ち主 | — | `[{url}]`(`https://github.com/login/oauth/authorize?client_id=&state=`) | 403 | +| `POST /accounts/{a}/github/candidates` | 持ち主 | `{code, state}`(認可の戻り。要素は 1 個) | `{proof, candidates: [{repository_id, full_name, installation_id, registered}]}` | 400 `invalid_request`(`state` が合わない・`code` が無いか換えられない)・403 | +| `GET /accounts/{a}/repositories` | 席を持つ人 | — | `[{repository_id, full_name, installation_id, registered_at, active_sprint_runs, max_concurrent_runs}]` | 403 | +| `POST /accounts/{a}/repositories` | 持ち主 | `{proof, repository_id}` | 登録の 1 行(`active_sprint_runs` を除く) | 400(証明の署名・期限・利用者・アカウントの違い)・403(証明に無い ID、登録の直前に導入の token を作れない。I33)・409 `repository_exists`(I32) | +| `DELETE /accounts/{a}/repositories?repository_id=` | 持ち主 | 鍵 `repository_id` | `{removed: true}` | 400(ID の形)・403・404 `repository_not_found`・409 `repository_in_use`(I36) | +| `GET /accounts/{a}/model-credential` | 持ち主 | — | `[{registered, kind, updated_at, external_id}]`。未登録なら `kind`・`updated_at` は `null`。`external_id` は常に返す(秘密でない) | 403 | +| `PUT /accounts/{a}/model-credential` | 持ち主 | `{kind: "anthropic_api_key", api_key}`(1〜512 字)か `{kind: "aws_role", role_arn, region}`(`^arn:aws[a-z-]*:iam::\d{12}:role/.+$` で 2,048 字まで、`^[a-z]{2}(-[a-z]+)+-\d$` で 32 字まで)。要素は 1 個 | 取得と同じ形(値も ARN も返さない) | 400・403・503 `credential_unavailable` | +| `DELETE /accounts/{a}/model-credential` | 持ち主 | 鍵なし(要素は 1 個) | `{removed: true}`(無くても成功) | 403・503 `credential_unavailable` | +| `POST /accounts/{a}/issue-candidates` | 席を持つ人 | `{repository_id, exclude?: [番号]}`(選び直しで外す番号) | `{source: "ranking" \| "recent", milestone, candidates: [{number, title, selected, reason}], note}` | 400(ID の形)・403・404 `repository_not_found` | +| `POST /accounts/{a}/sprint-runs` | 席を持つ人 | `{repository_id, issues, idempotency_key}`(`issues` は 1〜20 件の重なりの無い番号) | `{sprint_run_id, state: "starting"}` | 400・403・404・409・503(下の表) | +| `GET /accounts/{a}/sprint-runs?repository_id=` | 席を持つ人 | 条件 `repository_id`(省くとアカウント全体) | `[{sprint_run_id, repository_id, issues, state, failure_reason, created_at, repo, current_phase, current_step, last_event_at}]`(新しい順 50 件。後ろの 4 項目は進捗の事象から導く。規則は [スプリントの一覧と詳細の読み出し](ndf-factory-contracts-and-progress.md#スプリントの一覧と詳細の読み出し)) | 要求全体の 400(ID の形)・403・503 `progress_unavailable` | +| `POST /accounts/{a}/sprint-runs/cancel` | 席を持つ人 | `{sprint_run_id}` | `{state}`(`cancelled` か、停止を待つ `running`) | 403・404 `sprint_run_not_found`・409 `already_finished`・`release_after_main` | **持ち主だけの経路は、席を持つだけの利用者に他人のアカウントと同じ 403 `forbidden` を返す**(`requireOwner`)。チームのアカウントでは、リポジトリの登録と外し・認可・候補・キーの 3 つの操作は持ち主(`role` が `owner`)だけが、スプリントの開始・一覧・取り消しと課題の候補は席を持つ人が行う。 @@ -430,7 +430,7 @@ Workflows・Administration・Projects は持たない。Workflows は CI を書 | 観点 | 縛る振る舞い | | --- | --- | | 候補と登録(I32・I33) | 偽の GitHub が返した導入の対象のうち、利用者の届くものだけが候補に出ること。証明に無い ID・届かない導入の登録が 403 で行が増えないこと。同じ ID の 2 回目と同時の 2 本が 409 で 1 行であること | -| 外し(I36) | 終わりの状態でない実行があると 409、終われば 204 であること | +| 外し(I36) | 終わりの状態でない実行があると要素の 409、終われば成功の要素(`{removed: true}`)であること | | 他人のアカウント | 他人のアカウントへの経路が #2170 と同じ 403 の本文を返し、持ち主でない席の人の持ち主だけの操作も同じ 403 であること | | キーの持ち込み(I38) | 2 種類の保存・差し替え・消去が通り、応答が 4 項目だけであること。テストのキーの値が、応答・ログ・事象・`RunTask` の環境変数のどこにも無く、ARN は自分のアカウントの `byok` のジョブにだけ載ること | | 外部 ID | 外部 ID がアカウントごとに違い、`AssumeRole` に付くこと。偽の STS が断ると `failed` になること | diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index 05d31d582..ec8e2fc6c 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -443,6 +443,22 @@ stateDiagram-v2 `factory/api/src/index.ts` は `requestResume`・`claimLocalRunJob`・`reportLocalRunJobEnded`・`cancelSprintRun` を公開する。 +### 利用者向けの操作の形 + +画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。 + +| 種類 | メソッド | 入力 | 成功 | +| --- | --- | --- | --- | +| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) | +| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 | +| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 | + +- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす +- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である +- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`) +- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す +- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す + ### 常に成り立つ条件(サービス全体) | # | 集約 | 条件 | 破れたときの扱い | 縛る課題 | diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md index 3de09dbc1..c97be9abe 100644 --- a/docs/specifications/ndf-factory-sprint-views.md +++ b/docs/specifications/ndf-factory-sprint-views.md @@ -105,14 +105,16 @@ graph TD ### 経路(`factory/contracts/openapi/runs.yaml`) -| 経路 | 応答 | +入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。 + +| 経路 | 値 / 失敗 | | --- | --- | -| `GET /accounts/{accountId}/sprint-runs` | 200 `SprintRunList`。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 403 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}` | 200 `SprintRunDetail` / 403 / 503 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}` | 200 `StepDetail`。`stepKey` は事象の `idempotency_key` を URL で符号化したもの / 403 / 503 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/backlog` | 200 `Backlog` / 403 / 503(保存か GitHub の口の失敗) | +| `GET /accounts/{accountId}/sprint-runs` | 一覧。`SprintRunRow` の配列。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 要求全体の 403 | +| `GET /accounts/{accountId}/sprint-runs/detail?sprint_run_id=` | 指定の取得。`SprintRunDetail` / 要素の 403 / 要素の 503 | +| `GET /accounts/{accountId}/sprint-runs/steps?sprint_run_id=&step_key=` | 指定の取得。実行の ID は 1 つの条件で、鍵は工程の `step_key`(事象の `idempotency_key`)を繰り返す。`StepDetail` / 他人の実行は要求全体の 403、要素の 403 / 503 | +| `GET /accounts/{accountId}/backlogs?repository_id=` | 指定の取得。`Backlog` / 要素の 403 / 要素の 503(保存か GitHub の口の失敗) | -403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}` である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。 +403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}`(要素の結果では `{ok: false, status: 403, error}`)である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。 `SprintRunDetail`: diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md index 02c189884..8d2d654bf 100644 --- a/docs/specifications/ndf-factory-ui-components.md +++ b/docs/specifications/ndf-factory-ui-components.md @@ -88,9 +88,9 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分 | 操作 | 入力 | 出力 | 失敗の形 | | --- | --- | --- | --- | -| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunSummary[]`。見つからない ID の要素は返さない | `ApiFailure`(401・403・5xx) | +| `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunDetail[]`(入力と同じ順) | `ApiFailure`(401・要求全体か最初に失敗した要素の 403・5xx。無い実行も 403) | -`sprintRunDetails` の本物の実装は、詳細の経路がまだ無いため `GET /accounts/{a}/sprint-runs` の新しい順 50 件から ID で選ぶ。それより古い実行は見つからない扱いになる。詳細の経路ができたら本物の実装だけを差し替え、画面は変えない。 +`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形))。 ## つながりの状態 From 0091b3a4c62152a3a8e7f4ca9e6eb8ee5aefc9f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 23:23:33 +0000 Subject: [PATCH 20/39] =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E5=90=91?= =?UTF-8?q?=E3=81=91=E3=81=AE=E6=93=8D=E4=BD=9C=E3=81=AE=E5=BD=A2=E3=82=92?= =?UTF-8?q?=E5=A5=91=E7=B4=84=E3=81=AE=E5=AE=9A=E7=BE=A9=E3=81=AE=E7=A2=BA?= =?UTF-8?q?=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81?= =?UTF-8?q?=E5=85=A8=E4=BD=93=E3=81=AE=E5=A5=91=E7=B4=84=E3=82=92=E8=A1=8C?= =?UTF-8?q?=E6=95=B0=E3=81=AE=E4=B8=8A=E9=99=90=E3=81=AB=E5=8F=8E=E3=82=81?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2384=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ndf-factory-service-boundary.md が 516 行で上限 500 行を超え、doc-line-limit-check と pytest が落ちていた。 節は factory/contracts の操作の表を正とする契約の形のため、契約の定義と型の確定仕様へ移し、参照 6 件の行き先を合わせた。 Co-Authored-By: Claude Opus 5.5 --- .../ndf-factory-accounts-and-plans.md | 2 +- .../ndf-factory-contracts-and-progress.md | 16 ++++++++++++++++ ...ndf-factory-dev-loops-data-and-contracts.md | 2 +- ...ndf-factory-organizer-data-and-contracts.md | 2 +- .../ndf-factory-runs-and-runner.md | 2 +- .../ndf-factory-service-boundary.md | 18 +----------------- .../specifications/ndf-factory-sprint-views.md | 2 +- .../ndf-factory-ui-components.md | 2 +- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/specifications/ndf-factory-accounts-and-plans.md b/docs/specifications/ndf-factory-accounts-and-plans.md index 055d62680..4408d71d2 100644 --- a/docs/specifications/ndf-factory-accounts-and-plans.md +++ b/docs/specifications/ndf-factory-accounts-and-plans.md @@ -214,7 +214,7 @@ sequenceDiagram ### API の経路 -経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。 +経路はすべて `Authorization: Bearer `(または委任の資格情報)を要る。エラーの本文は `{"error": {"code": "<機械の値>", "message": "<利用者に出す文>"}}` に揃える。機械可読な正は `factory/contracts/openapi/accounts.yaml` である。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である(401 は要求全体)。チームの作成と席の追加・変更・削除は人だけが確定でき、委任の資格情報では要求全体を 403 `human_only` で断る。 | 経路 | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | diff --git a/docs/specifications/ndf-factory-contracts-and-progress.md b/docs/specifications/ndf-factory-contracts-and-progress.md index 7f31250ad..d2ad50a79 100644 --- a/docs/specifications/ndf-factory-contracts-and-progress.md +++ b/docs/specifications/ndf-factory-contracts-and-progress.md @@ -362,6 +362,22 @@ sequenceDiagram - **`SavedAnswerReader` は `(approvalRequestId) => Promise` である。** `SavedAnswer` は `{ approvalRequestId; sprintRunId; kind; decision?; answeredByAccountId }` で、`decision` は判断(`kind` が `decision`)では持たない。`null` は未保存、読めないときは投げる(受け口が 503 にする) - **handler は `savedAnswers` に `saved-answers.ts` の `savedAnswersFromEnv()` を渡す。** 今は常に `SavedAnswersUnavailableError` を投げる既定で、`api` の経路は 503 を返す。#2173 が同じファイルの中身を本物の読み出しに替え、受け口のほかのファイルは変えずに済む +### 利用者向けの操作の形 + +画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。 + +| 種類 | メソッド | 入力 | 成功 | +| --- | --- | --- | --- | +| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) | +| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 | +| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 | + +- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす +- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である +- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`) +- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す +- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す + ### 常に成り立つ条件 I10 は全体の契約のもの。#2170 の I24 に続けて番号を振る。I37 は #2186 が #2216 の照合として足し、I44・I45・I50 は #2216 が足した(I32〜I36・I38〜I43 は #2186 のほかの集約の条件、I46〜I49 は [起票の依頼と起票の代行](ndf-factory-issue-requests.md) の台帳の条件)。 diff --git a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md index 70dc77a8e..d153414b6 100644 --- a/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md +++ b/docs/specifications/ndf-factory-dev-loops-data-and-contracts.md @@ -150,7 +150,7 @@ erDiagram ### 画面向けの経路(`openapi/loops.yaml`・`openapi/runs.yaml`) -どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 +どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。ループの要素はループの `accountId` が一致しなければ 403、無ければ 404 `loop_not_found` の要素の結果になる。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 | 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md index 44af6d06b..7677be2ca 100644 --- a/docs/specifications/ndf-factory-organizer-data-and-contracts.md +++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md @@ -101,7 +101,7 @@ erDiagram ### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`) -入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。 +入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗の形」は要素の結果の状態である(401 は要求全体)。 | 経路 | 要素・条件 | 値 | 失敗の形 | | --- | --- | --- | --- | diff --git a/docs/specifications/ndf-factory-runs-and-runner.md b/docs/specifications/ndf-factory-runs-and-runner.md index a75598c46..47eb7c853 100644 --- a/docs/specifications/ndf-factory-runs-and-runner.md +++ b/docs/specifications/ndf-factory-runs-and-runner.md @@ -149,7 +149,7 @@ graph TD ### 画面向けの経路 -機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 +機械可読な正は [`factory/contracts/openapi/runs.yaml`](../../factory/contracts/openapi/runs.yaml) である。どの経路も Cognito の access token か委任の資格情報を要り、`resolveFromRequest` でアカウントを解決する。他人のアカウントは #2170 の I18 と同じ 403 である([認証とアカウント・席・料金プランごとの使える量](ndf-factory-accounts-and-plans.md))。エラーの本文は `{"error": {"code", "message", "details"?}}`。入力・成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。下の表の「要素・条件」は要素 1 個の項目か一覧の条件、「値」は成功の要素の `value`(一覧は配列の 1 件)、「失敗」は要素の結果の状態である。 | 経路 | 誰が | 要素・条件 | 値 | 失敗 | | --- | --- | --- | --- | --- | diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index ec8e2fc6c..f70b1c18e 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -443,22 +443,6 @@ stateDiagram-v2 `factory/api/src/index.ts` は `requestResume`・`claimLocalRunJob`・`reportLocalRunJobEnded`・`cancelSprintRun` を公開する。 -### 利用者向けの操作の形 - -画面・委任を受けたエージェント・MCP のクライアントが呼ぶ利用者向けの操作(契約の操作に `x-ndf-access` を持つもの)は、どれも次の形で受け渡す。形の正は `factory/contracts/openapi/*.yaml` の拡張(`x-ndf-access`・`x-ndf-keys`・`x-ndf-id`)と、そこから生成する操作の表 `factory/contracts/src/generated/operations.ts`(`npm run generate -w contracts -- --check` で食い違いを落とす)である。ランナー向けの経路(`/run-jobs/*`)と進捗の受け口(`/ingest/*`)はこの形に入らない。 - -| 種類 | メソッド | 入力 | 成功 | -| --- | --- | --- | --- | -| 一覧 | `GET` | 条件の query | 200 と値の配列(要素の結果で包まない) | -| 指定の取得・削除 | `GET`・`DELETE` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`)。鍵を持たない操作は query 無しで要素 1 個 | 200 と要素の結果の配列 | -| 作成・更新・操作 | `POST`・`PATCH`・`PUT` | 本文の要素の配列(1 件でも要素 1 個の配列) | 200 と要素の結果の配列 | - -- **経路の変数はアカウントの ID だけ**である。実行・ループ・リポジトリなどの識別子は鍵の query か要素の項目で渡し、要素ごとにそのアカウントに属するかを照らす -- **要素の結果**は入力と同じ順・同じ数で、成功は `{ok: true, value}`、失敗は `{ok: false, status, error: {code, message, details?}}` である。失敗の `status` と `code` は、各操作の確定仕様の表にある誤りのまま(400・403・404・409・422・503)。要素がすべて失敗しても応答は 200 である -- **要求全体を断るのは次だけ**である。本文が配列でない(400 `invalid_request`)・要素が 0 個(400 `empty_items`)・101 個以上(400 `too_many_items`)・同じ鍵の要素が 2 つ(400 `duplicate_key`)・資格情報が無効(401)・経路のアカウントを解決できない(403 `forbidden`)・委任の資格情報で人だけが確定できる操作を呼んだ(403 `human_only`)・読み取りだけの委任で書き込みの操作を呼んだ(403 `scope_insufficient`)・操作の記録を追記できない(503 `audit_unavailable`) -- **新しい識別子を作る作成**(スプリントの実行・開発ループ・チーム・委任の資格情報)は、要素ごとに冪等の鍵 `idempotency_key`(1〜64 字の英数字とハイフン)を受ける。同じ利用者・同じ操作・同じ鍵の 24 時間以内の 2 回目は、1 回目に作ったものを返す -- 画面の API クライアント(`factory/web/src/api.ts`)は要求を操作の表から組み、要素の結果をほどいて 1 件の値か `ApiFailure` を返す - ### 常に成り立つ条件(サービス全体) | # | 集約 | 条件 | 破れたときの扱い | 縛る課題 | @@ -510,7 +494,7 @@ stateDiagram-v2 - 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177) - サービスのディレクトリ: [factory/README.md](../../factory/README.md) - 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) -- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) +- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171)、[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形)(#2384): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) - 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json) - 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md) - 版と配布の規則(ランナーが導入する NDF に当たる): [版と配布の正本](../versioning-and-distribution.md) diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md index c97be9abe..72d888115 100644 --- a/docs/specifications/ndf-factory-sprint-views.md +++ b/docs/specifications/ndf-factory-sprint-views.md @@ -105,7 +105,7 @@ graph TD ### 経路(`factory/contracts/openapi/runs.yaml`) -入力と成功の形は [利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。 +入力と成功の形は [利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形) に従う。「値」は一覧の 1 件か、成功の要素の `value` である。 | 経路 | 値 / 失敗 | | --- | --- | diff --git a/docs/specifications/ndf-factory-ui-components.md b/docs/specifications/ndf-factory-ui-components.md index 8d2d654bf..6e3eca86d 100644 --- a/docs/specifications/ndf-factory-ui-components.md +++ b/docs/specifications/ndf-factory-ui-components.md @@ -90,7 +90,7 @@ Neural Development Factory の Web の画面を組む部品の一覧と使い分 | --- | --- | --- | --- | | `sprintRunDetails(accountId, sprintRunIds)` | アカウントの ID と実行の ID の配列 | `SprintRunDetail[]`(入力と同じ順) | `ApiFailure`(401・要求全体か最初に失敗した要素の 403・5xx。無い実行も 403) | -`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-service-boundary.md#利用者向けの操作の形))。 +`sprintRunDetails` の本物の実装は、実行の ID を鍵の query に繰り返して `GET /accounts/{a}/sprint-runs/detail` を 1 回呼び、要素の結果をほどく([利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形))。 ## つながりの状態 From ff65626257d7c79c8d5f0ef514779ff8373470fe Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 23:40:31 +0000 Subject: [PATCH 21/39] =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E8=AA=8D=E5=8F=AF?= =?UTF-8?q?=E3=82=92=E3=80=81=E8=A6=81=E7=B4=A0=E3=81=94=E3=81=A8=E3=81=AB?= =?UTF-8?q?=E7=85=A7=E3=82=89=E3=81=99=E7=B5=90=E6=9E=9C=E3=81=AE=E5=BD=A2?= =?UTF-8?q?=E3=81=B8=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B=EF=BC=88#2384?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-factory-dev-loops.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specifications/ndf-factory-dev-loops.md b/docs/specifications/ndf-factory-dev-loops.md index aee611953..7c889c122 100644 --- a/docs/specifications/ndf-factory-dev-loops.md +++ b/docs/specifications/ndf-factory-dev-loops.md @@ -370,7 +370,7 @@ sequenceDiagram ## セキュリティ - サービスは `local` のアカウントと認証を知らない。利用上限の事象はリセット時刻だけを持つ -- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後にループの `accountId` と登録を照らし、他人は 403、無ければ 404 になる +- ループの操作は、そのリポジトリの登録を持つアカウントだけが行える。ループの経路は `resolveFromRequest` の後に要素ごとにループの `accountId` と登録を照らし、他人のループの要素は 403、無ければ 404 の要素の結果になる - 同時の実行の数の宣言は持ち主だけが変えられる ## テスト観点 From 1dfad18a0fe3a8b445bb4f3b959cd5b43c9298e2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 4 Nov 2026 23:55:19 +0000 Subject: [PATCH 22/39] =?UTF-8?q?docs:=20=E9=96=A2=E9=80=A3=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=81=AE=E8=A1=8C=E3=81=8B=E3=82=89=E8=AA=B2?= =?UTF-8?q?=E9=A1=8C=E7=95=AA=E5=8F=B7=E3=81=AE=E7=94=B1=E6=9D=A5=E3=82=92?= =?UTF-8?q?=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-factory-service-boundary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specifications/ndf-factory-service-boundary.md b/docs/specifications/ndf-factory-service-boundary.md index f70b1c18e..d8db81a5a 100644 --- a/docs/specifications/ndf-factory-service-boundary.md +++ b/docs/specifications/ndf-factory-service-boundary.md @@ -494,7 +494,7 @@ stateDiagram-v2 - 設計 Pull Request: [#2175](https://github.com/devbasex/ai-plugins/pull/2175)、実装: [#2177](https://github.com/devbasex/ai-plugins/pull/2177) - サービスのディレクトリ: [factory/README.md](../../factory/README.md) - 認証とアカウント・席・料金プランの使える量: [ndf-factory-accounts-and-plans.md](ndf-factory-accounts-and-plans.md) -- 契約の定義と型・進捗の受け口・実行ジョブの token(#2171)、[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形)(#2384): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) +- 契約の定義と型・進捗の受け口・実行ジョブの token・[利用者向けの操作の形](ndf-factory-contracts-and-progress.md#利用者向けの操作の形): [ndf-factory-contracts-and-progress.md](ndf-factory-contracts-and-progress.md) - 境界の検査: [scripts/check-service-boundary.py](../../scripts/check-service-boundary.py)、許可の一覧: [scripts/service-boundary-allow.json](../../scripts/service-boundary-allow.json) - 追加待ちの扱い: [必須のチェックの突き合わせ](ci-required-checks-reconcile.md) - 版と配布の規則(ランナーが導入する NDF に当たる): [版と配布の正本](../versioning-and-distribution.md) From d5dfbfe4b3508a0e0b5ae0db2fb3642745ad0c93 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:09:41 +0000 Subject: [PATCH 23/39] =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=81=AE=E5=9C=B0?= =?UTF-8?q?=E5=9B=B3=E3=81=AE=E4=BA=88=E5=AE=9A=E3=81=AE=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=81=AB=E3=80=81=E3=81=9D=E3=81=AE=E7=94=BB=E9=9D=A2=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E3=82=8B=E8=AA=B2=E9=A1=8C=E3=82=92=E6=9B=B8=E3=81=8F?= =?UTF-8?q?=EF=BC=88#2431=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 予定の 3 行(承認・請求と使用量・手元のマシン)の載る機能の末尾に、作る課題の番号と題を括弧で添える。冒頭の手順に、作る課題がその行を「あり」へ変えるときの扱いを足す。 Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-factory-screen-map.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specifications/ndf-factory-screen-map.md b/docs/specifications/ndf-factory-screen-map.md index 0a26ac8ae..6c5990a20 100644 --- a/docs/specifications/ndf-factory-screen-map.md +++ b/docs/specifications/ndf-factory-screen-map.md @@ -1,6 +1,6 @@ # Neural Development Factory: 画面の地図 -Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。 +Neural Development Factory の Web の画面の全体を 1 つの表にまとめる。どの経路にどの画面があり、どの役割が使い、確定仕様のどの機能がどの画面に載るかを示す。画面を足す課題は、この表に 1 行を足し、`factory/web/src/screens.ts` に同じ行を足し、翻訳のファイルにキーを足し、[画面の部品](ndf-factory-ui-components.md)で画面を組む。予定の行に作る課題として書かれた課題は、行を足さずにその行の状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ括弧を消す)。 ## 概要 @@ -41,9 +41,9 @@ Neural Development Factory の Web の画面の全体を 1 つの表にまとめ | `/accounts/:accountId/loops/:loopId` | ループの詳細 | 個人・管理者・席 | あり | 周回・周回の実行と区間の数・利用上限の再開の時刻・人の判断待ちと答え・予算の変更・止める・次の周回を始める | | `/accounts/:accountId/repositories/:repositoryId/backlog` | バックログ | 個人・管理者・席 | あり | 順位の表と棚卸しの記録(読むだけ) | | `/delegations` | エージェントへの委任 | 個人・管理者・席 | あり | 委任の資格情報の発行(値は発行の直後に 1 度だけ出す)・一覧(範囲・期限・状態)・取り消し。オフラインの間は発行と取り消しをしない。自分のアカウントからたどる | -| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | -| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | -| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | +| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知(作る課題: #2173 承認ゲートの通知(Slack を含む)と Web からの承認) | +| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済(作る課題: #2174 サブスクリプションと Bedrock の token 課金) | +| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍(作る課題: #2189 手元のマシン(利用者の環境で、利用者の claude ログインで実行する)) | ## ナビゲーション From 8203279085021b737007aec6ce0dcbaf686eed22 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:34:38 +0000 Subject: [PATCH 24/39] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration=20?= =?UTF-8?q?=E2=80=94=20factory/api/src/audit/middleware.ts#recordOf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 委任の資格情報の頭 ndfd_ を delegations/model.ts の CREDENTIAL_PREFIX に寄せる。 auth/resolve.ts の DELEGATION_PREFIX をそこから組み、判定を isDelegationAuthorization として公開し、 認証の分岐(E2)と操作の記録の via の判定(I16)が同じ関数を使う。 delegations/service.ts の値の正規表現も CREDENTIAL_PREFIX から組む。振る舞いは変えない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- factory/api/src/audit/middleware.ts | 4 ++-- factory/api/src/auth/resolve.ts | 12 +++++++++--- factory/api/src/delegations/service.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/factory/api/src/audit/middleware.ts b/factory/api/src/audit/middleware.ts index ab9e478ff..570647e8b 100644 --- a/factory/api/src/audit/middleware.ts +++ b/factory/api/src/audit/middleware.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import type { Context, MiddlewareHandler } from "hono"; import { OPERATIONS, findOperation, type Operation } from "@ndf-factory/contracts/operations"; -import { isMcpInternal, type AuthEnv } from "../auth/resolve.ts"; +import { isDelegationAuthorization, isMcpInternal, type AuthEnv } from "../auth/resolve.ts"; import { ServiceUnavailableError } from "../errors.ts"; import { toItem, type AuditLog, type AuditOutcome, type AuditRecord } from "./log.ts"; @@ -36,7 +36,7 @@ async function recordOf(c: Context, at: string): Promise { const caller = c.get("caller"); const trace = c.get("operation"); // 呼び出し元の種類は資格情報の種類と内部の印だけで決め、外から届くほかのヘッダーを見ない(I16) - const delegated = (c.req.header("Authorization") ?? "").startsWith("Bearer ndfd_"); + const delegated = isDelegationAuthorization(c.req.header("Authorization")); const via = caller?.via ?? (isMcpInternal(c.env) ? "mcp" : delegated ? "api" : "screen"); const matched = trace ? { op: findOperation(trace.operationId), accountId: trace.accountId } : match(c.req.method, c.req.path); const status = c.res.status; diff --git a/factory/api/src/auth/resolve.ts b/factory/api/src/auth/resolve.ts index 3d19932cf..2dd8eb5c2 100644 --- a/factory/api/src/auth/resolve.ts +++ b/factory/api/src/auth/resolve.ts @@ -3,6 +3,7 @@ import type { AccountService } from "../accounts/service.ts"; import type { AccountContext, Caller } from "../accounts/model.ts"; import type { AuditLog } from "../audit/log.ts"; import type { DelegationService } from "../delegations/service.ts"; +import { CREDENTIAL_PREFIX } from "../delegations/model.ts"; import { AuthError } from "../errors.ts"; import { verifyAuthorization, type AccessTokenVerifier } from "./verify.ts"; @@ -22,12 +23,17 @@ export function isMcpInternal(env: unknown): boolean { return typeof env === "object" && env !== null && (env as Record)[MCP_INTERNAL] === true; } -const DELEGATION_PREFIX = "Bearer ndfd_"; +const DELEGATION_PREFIX = `Bearer ${CREDENTIAL_PREFIX}`; + +// Authorization の値が委任の資格情報か(頭で決める)。認証の分岐(E2)と記録の via(I16)が同じ判定を使う +export function isDelegationAuthorization(header: string | undefined): boolean { + return header?.startsWith(DELEGATION_PREFIX) ?? false; +} // Authorization の値を、委任の資格情報(頭が ndfd_)か Cognito の access token として検証する(E2)。どの失敗も 401 export async function verifyCaller(deps: AuthDeps, authorization: string | undefined, mcp: boolean): Promise { - if (authorization?.startsWith(DELEGATION_PREFIX)) { - const verified = deps.delegations ? await deps.delegations.verify(authorization.slice("Bearer ".length)) : null; + if (isDelegationAuthorization(authorization)) { + const verified = deps.delegations ? await deps.delegations.verify(authorization!.slice("Bearer ".length)) : null; if (!verified) throw new AuthError(); return { userId: verified.userId, diff --git a/factory/api/src/delegations/service.ts b/factory/api/src/delegations/service.ts index 7d38239c6..a2844788a 100644 --- a/factory/api/src/delegations/service.ts +++ b/factory/api/src/delegations/service.ts @@ -36,7 +36,7 @@ function base32(bytes: Buffer): string { return out; } -const VALUE = /^ndfd_([a-z2-7]{26})_([A-Za-z0-9_-]{43})$/; +const VALUE = new RegExp(`^${CREDENTIAL_PREFIX}([a-z2-7]{26})_([A-Za-z0-9_-]{43})$`); const sha256 = (secret: string) => createHash("sha256").update(secret, "utf8").digest("hex"); // 照合が通った委任の資格情報。人の直の操作と同じ Caller を組むための値と、範囲を持つ From 510ee2291b30237bc21bb59a61df60c4ac4b87ea Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:35:06 +0000 Subject: [PATCH 25/39] =?UTF-8?q?Refactor:=20fix=5Fdependency=5Fdirection?= =?UTF-8?q?=20=E2=80=94=20factory/api/src/routes/factory.ts#positiveInt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit factory.ts と sprint-views.ts が実行時に互いを読む循環を外す。入力検証の positiveInt を 共通の経路層 routes/operation.ts へ移し、両方がそこから直接読む。factory.ts からの再公開は残さず、 sprint-views.ts の FactoryDeps は type import のままにする。影響範囲は routes の 3 ファイルだけで、振る舞いは変えない。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- factory/api/src/routes/factory.ts | 8 +------- factory/api/src/routes/operation.ts | 6 ++++++ factory/api/src/routes/sprint-views.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/factory/api/src/routes/factory.ts b/factory/api/src/routes/factory.ts index 758a237fd..ccc7cdb08 100644 --- a/factory/api/src/routes/factory.ts +++ b/factory/api/src/routes/factory.ts @@ -14,7 +14,7 @@ import type { RunControl } from "../runs/control.ts"; import type { LoopControl } from "../runs/loops.ts"; import type { RunsTable } from "../runs/table.ts"; import { parseIdempotencyKey } from "../idempotency.ts"; -import { registerList, registerOperation } from "./operation.ts"; +import { positiveInt, registerList, registerOperation } from "./operation.ts"; import { registerSprintViews, sprintRunList } from "./sprint-views.ts"; export interface FactoryDeps { @@ -30,12 +30,6 @@ export interface FactoryDeps { reader: ProgressReader; } -export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number { - const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw; - if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message); - return n as number; -} - function idOf(raw: unknown, message: string): string { if (typeof raw !== "string" || raw === "") throw new InvalidRequestError(message); return raw; diff --git a/factory/api/src/routes/operation.ts b/factory/api/src/routes/operation.ts index 989f28f90..837d78f28 100644 --- a/factory/api/src/routes/operation.ts +++ b/factory/api/src/routes/operation.ts @@ -9,6 +9,12 @@ import { runElements, type ElementResult } from "./elements.ts"; export type Element = Record; +export function positiveInt(raw: unknown, message = "リポジトリの ID の形ではありません"): number { + const n = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw; + if (!Number.isInteger(n) || (n as number) < 1) throw new InvalidRequestError(message); + return n as number; +} + export interface ElementHandler { // 要求全体の前に 1 度だけ行う(条件の照合など)。投げると要求全体を断る prepare?: (c: Context, ctx: AccountContext | null) => Promise; diff --git a/factory/api/src/routes/sprint-views.ts b/factory/api/src/routes/sprint-views.ts index ae8559ea5..941a1103a 100644 --- a/factory/api/src/routes/sprint-views.ts +++ b/factory/api/src/routes/sprint-views.ts @@ -9,8 +9,8 @@ import type { RankingTable } from "../github/port.ts"; import type { SprintRun } from "../runs/model.ts"; import { DETAIL_STEP_LIMIT, deriveDetail, deriveRefinements, deriveStepDetail, listRowExtras, stepRows } from "../views/derive.ts"; import { mapLimit } from "./elements.ts"; -import { positiveInt, type FactoryDeps } from "./factory.ts"; -import { registerOperation } from "./operation.ts"; +import type { FactoryDeps } from "./factory.ts"; +import { positiveInt, registerOperation } from "./operation.ts"; // 一覧の行ごとの読み出しを並べる数の上限 const LIST_CONCURRENCY = 10; From 50524082ed19664f59f61ed9cd061e84d8c89ad4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:36:44 +0000 Subject: [PATCH 26/39] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20factory/api/src/runs/memory-table.ts#MemoryRunsTable.createR?= =?UTF-8?q?un?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createRun の拒否の判定を refuseCreate に、全条件が通った後の書き込みを commitCreate に抽出した。 拒否条件の順序と「どれかが当たれば何も書かない」境界をメソッドの切れ目で示す。振る舞いは変えていない。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- factory/api/src/runs/memory-table.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/factory/api/src/runs/memory-table.ts b/factory/api/src/runs/memory-table.ts index 32368112c..cd63b6635 100644 --- a/factory/api/src/runs/memory-table.ts +++ b/factory/api/src/runs/memory-table.ts @@ -148,6 +148,14 @@ export class MemoryRunsTable implements RunsTable { async createRun(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): Promise { await this.tick(); + const refused = this.refuseCreate(run, expected, guards); + if (refused) return refused; + this.commitCreate(run, job, expected, guards); + return "created"; + } + + // 作成を拒む理由。条件はこの順で確かめ、どれか 1 つでも当たれば何も書かない + private refuseCreate(run: SprintRun, expected: Slots, guards?: CreateRunGuards): CreateRunResult | null { const repoKey = regKey(run.accountId, run.repositoryId); if ((this.accountSlots.get(run.accountId) ?? 0) !== expected.account) return "slots"; if ((this.repositorySlots.get(repoKey) ?? 0) !== expected.repository) return "slots"; @@ -160,6 +168,12 @@ export class MemoryRunsTable implements RunsTable { if (guards.loop && !this.loopFits(guards.loop)) return "loop_changed"; if (guards.idempotency && !this.idempotencyFree(guards.idempotency)) return "duplicate"; } + return null; + } + + // 全条件が通った後の書き込み + private commitCreate(run: SprintRun, job: RunJob, expected: Slots, guards?: CreateRunGuards): void { + const repoKey = regKey(run.accountId, run.repositoryId); this.accountSlots.set(run.accountId, expected.account + 1); this.repositorySlots.set(repoKey, expected.repository + 1); if (guards) { @@ -173,7 +187,6 @@ export class MemoryRunsTable implements RunsTable { } this.runs.set(run.sprintRunId, structuredClone({ ...run, ...(guards ? { legacy: guards.legacy } : {}) })); this.jobs.set(job.jobId, structuredClone(job)); - return "created"; } async getRun(sprintRunId: string) { From 569aa668b4a7b9b75ce101f4dcc2806187aa3171 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:52:11 +0000 Subject: [PATCH 27/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=82=92=E7=89=88=E6=95=B0=E3=83=BB=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E3=81=AE=20PR=E3=83=BB=E5=AE=9F=E8=A1=8C=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E3=83=BB=E6=B5=81=E3=81=97=E7=9B=B4=E3=81=97=E3=83=BB=E9=96=8B?= =?UTF-8?q?=E7=99=BA=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E7=89=88=E3=81=AB?= =?UTF-8?q?=E7=B5=9E=E3=82=8A=E3=80=81=E8=A8=AD=E5=AE=9A=E3=81=A8=E4=BA=88?= =?UTF-8?q?=E7=AE=97=E3=81=AE=E5=8F=82=E7=85=A7=E3=81=A8=E5=BD=A2=E3=82=92?= =?UTF-8?q?=E6=B1=BA=E3=82=81=E3=81=9F=E8=A8=AD=E5=AE=9A=E3=81=AE=E7=85=A7?= =?UTF-8?q?=E5=90=88=E3=82=92=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計の承認ゲート 1 の利用者の指摘に合わせる。 - 要求: 予算と宣言の値を参照で持つ部分を外し、受け入れ条件 1 を版数と記録の PR に縮め、秘密の入力の受け入れ条件を消した(以降の番号を詰めた) - 決定 4 を「設定の値と予算は参照にせず、組んだ時点の設定とも照らさない」にした - 不変条件から形を決めた設定の照合(旧 I7)と秘密の設定(旧 I9)を消し、I3 と版の決め直しの事前判定から設定の照合の段を外した - 参照の種類から setting・budget を、構成要素から設定の契約を外し、差し込みを書く 7 ファイルを検索で数え直した - 用語集から「形を決めた設定」を消した Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 +- docs/glossary/glossary.json | 8 +- issues/issue-2344-design-decisions.md | 33 +++----- issues/issue-2344-design.md | 116 ++++++++------------------ issues/issue-2344-requirements.md | 50 +++++------ 5 files changed, 78 insertions(+), 132 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 402702877..2f74be191 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -383,11 +383,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | -| プランの参照 | — | プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・`.ndf/` の設定の 1 つのキー・上限の宣言の 1 つの方針など)。流す直前に実行の核が値へ解決する(#2344) | — | — | — | +| プランの参照 | — | プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・前のプランが出した PR・承認したコミットと照らすブランチの先端)。流す直前に実行の核が値へ解決する(#2344) | — | — | — | | 解決の記録 | — | 1 回の実行がプランの参照ごとに解決した値と出所。実行の記録の行として残り、プランのファイルへは書き戻さない(#2344) | — | — | — | | 承認時の値 | — | 承認ゲートで人か MVV 判定が承認資料で見た値(承認したコミット・版)。流す直前に解決した値と照らし、食い違えば流さずに人へ戻す(#2344) | — | — | — | | 解決の控え | — | 流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344) | — | — | — | -| 形を決めた設定 | — | プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値の sha256 をプランが控え、最初のステップの前に今の値と照らし、違えば流さない(#2344) | — | — | — | | プランのハッシュ | — | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る(#2344) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 388b8b350..4ad4e86b0 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5307,7 +5307,7 @@ { "term": "プランの参照", "context": "ndf-workflow", - "meaning": "プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・`.ndf/` の設定の 1 つのキー・上限の宣言の 1 つの方針など)。流す直前に実行の核が値へ解決する(#2344)", + "meaning": "プランが値の代わりに持つ、値の取り先の名前(次に空いている開発版・課題のリリース記録を置いた PR・前のプランが出した PR・承認したコミットと照らすブランチの先端)。流す直前に実行の核が値へ解決する(#2344)", "source": "" }, { @@ -5328,12 +5328,6 @@ "meaning": "流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`)(#2344)", "source": "" }, - { - "term": "形を決めた設定", - "context": "ndf-workflow", - "meaning": "プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値の sha256 をプランが控え、最初のステップの前に今の値と照らし、違えば流さない(#2344)", - "source": "" - }, { "term": "プランのハッシュ", "context": "ndf-workflow", diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md index 9b848894c..0df80987c 100644 --- a/issues/issue-2344-design-decisions.md +++ b/issues/issue-2344-design-decisions.md @@ -1,7 +1,7 @@ # #2344 の設計: 決定の記録 設計文書の本体は [issue-2344-design.md](issue-2344-design.md) にある。要求の未決のうち、参照の書き方は決定 1、流し直しの判定は決定 7、 -版の記録の置き場は決定 3、参照へ移す設定のキーは決定 4 が受ける。 +版の記録の置き場は決定 3 が受ける。参照へ移す設定のキーの一覧は、設定を参照にしない(決定 4)ため要らない。 ## 決定の記録 @@ -43,32 +43,25 @@ 根拠: Value 7 / Value 8(MVV 版 5) -### 決定 4: 古い設定の値で黙って進まないよう、設定は「ステップの中身に入る値」を参照にし、「プランの形を決めた値」は組んだ時点の値を控えて流す前に照らす +### 決定 4: 範囲を起きた不具合に根拠のあるものに収めるよう、設定の値と予算は参照にせず組んだ時点の値で持ち、組んだ時点の設定とも照らさない -設定には 2 種類ある。 +この柱が参照にするのは、版数・記録の PR・前のプランの結果・承認したコミットと照らす先端の 4 種類である。版数と記録の PR は、プランを組んだ後に値が変わって +止まった事例がある(#2164・#2230・#2354)。前のプランの結果と先端の照合は、今も同じキューの中で流す直前に解決している手当てで、解決の規則を 1 か所にするために同じ形へ載せる。 +子の課題 4 件(#2230・#2164・#2349・#2354。要求の「子の課題」の表)に、`.ndf/` の設定から読んだ値か予算の秒が組んだ後に変わったことを原因とするものは無い。 +そのため設定の値と予算は今と同じく、プランを組むエントリポイントが組んだ時点の値で書く。設定を変えたときにプランへ反映するには、今と同じくプランを組み直す。 -| 種類 | 例 | 流す時点で読み直すと | -| --- | --- | --- | -| ステップの中身に入るだけの値 | 導入の確認のコマンド・上限の秒 | 流す直前の値を使えばよい | -| どのステップを置くかを決めた値 | 配布のテンプレート・本番のブランチ | ステップは増減しない | +設定(導入の確認のコマンド・配布のテンプレート・本番のブランチなど 11 キー)と予算を参照の仕組みに載せる形は採らない。 +組んだ時点の設定の値をプランへ控え、流す前に今の値と照らして違えば止める形も採らない。どちらも起きた不具合に根拠が無く、 +設定の契約・プランの型・プランの実行の順序に足す欄と判定が、この柱の承認資料の量を増やすためである(利用者の範囲の指示、2026-11-05)。 -後者を流す時点の値で使うと、組んだ形と設定が食い違ったまま流れる。そこで後者は、組んだ時点の値の sha256 だけをプランに控える(形を決めた設定)。 -最初のステップの前に今の値と照らし、違えば組み直すコマンドを添えて止まる。 -どちらに当たるかは設定の契約のキーの行(`plan`)が持ち、設計の表は契約からコピーした一覧とする。 +根拠: Value 2 / Value 3 / Value 4(MVV 版 5) -すべての設定を参照にする形は採らない。形を決めた値を流す時点で読み直しても、プランのステップは組み直さない限り変わらないためである。 -形を決めた値を照らさない形は、目的の 2 つ目(古い値で黙って進まない)に反するため採らない。 +### 決定 5: 利用者が明示した版を AI が置き換えないよう、明示した版は版の参照の `explicit` に値のまま入れる -根拠: Mission / Value 6 / Value 7(MVV 版 5) - -### 決定 5: 利用者が明示した値を AI が置き換えないよう、引数で渡した上限・ブランチは参照にせず値のまま持ち、明示した版は版の参照の `explicit` に値のまま入れる - -`--version` などで渡した値は、利用者か conductor がその値で流すと決めたものである。参照にして流す時点の値へ置き換えると、明示した決定を黙って上書きする。 -上限とブランチは値のまま持ち、解決の記録には出所「プランの値(明示)」を残す。流した値の出所を追えるようにするためである。 -版だけは、明示の値にも確かめることがある。#2164 でも明示の版は決め直さず、既出なら止める。承認ゲート 2 の後は承認した版とも照らす(決定 6)。 +`--version` で渡した版は、利用者か conductor がその版で流すと決めたものである。参照にして流す時点の版へ置き換えると、明示した決定を黙って上書きする。 +一方で明示の版にも確かめることがある。#2164 でも明示の版は決め直さず、既出なら止める。承認ゲート 2 の後は承認した版とも照らす(決定 6)。 そこで `--version` の値は版の参照の宣言の `params.explicit` に入れ、参照の部品が値を置き換えずに既出の判定と承認した版との照合を通す。解決の記録の出所は `plan:explicit` である。 -上限とブランチの明示の値も参照に寄せる形は採らない。上限とブランチでは明示の値に確かめることが無く、宣言が増えるだけのためである。 明示の版を参照にせずリテラルで持つ形は採らない。既出の判定と承認した版との照合を通る経路が無くなるためである。 根拠: Vision / Value 2(MVV 版 5) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index cbcb97c4b..51e9eca20 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -1,8 +1,8 @@ -# プランの参照: プランを作り直さずに版・記録の PR・設定・予算を流す直前の値で使い、承認時の値と食い違えば止まる(#2344) +# プランの参照: プランを作り直さずに版・記録の PR を流す直前の値で使い、承認時の値と食い違えば止まる(#2344) ## 目的 -- **今どうなっているか**: プランは版・記録の PR・設定から読んだ値・予算の秒を、組んだ時点の値で持つ。版と記録の PR だけはキューが流す前に差し込みを埋めるが、埋めた値でプランのファイルを上書きし、差し込みの種類ごとに別の関数が解決する。`--from` で始めると実行条件を見ずに流れ(#2349)、開発ループのスプリントは明示の版しか持てない(#2354) +- **今どうなっているか**: プランは版と記録の PR を、組んだ時点の値か差し込みで持つ。差し込みはキューが流す前に埋めるが、埋めた値でプランのファイルを上書きし、差し込みの種類ごとに別の関数が解決する。`--from` で始めると実行条件を見ずに流れ(#2349)、開発ループのスプリントは明示の版しか持てない(#2354) - **誰に効くか**: NDF でスプリントを回す conductor と利用者。並行するスプリントの配布の後にプランを作り直す手当て(#2164 の再発 2 回)と、完了したプランの流し直し(#2349 の 24 分・$0.89)が無くなる - **変えると何が成り立つか**: プランは値の取り先(参照)を持ち、参照の部品がステップの直前に 1 つの規則で解決・記録・照合する。解決できない・承認時の値と食い違うときは、推定の値で進まずに `stopped` か `gate` で返る @@ -12,7 +12,8 @@ - プランを組むエントリポイント(`supervise.py new sprint` / `new close`、`sprint-state.py loop sprint`) - プランを流す処理(キュー・プランの実行・再開のエントリポイント) - 配布の本番のエントリポイント -- **プロジェクトごとに違うもの**: 参照の値は各リポジトリの `.ndf/` の設定・origin の版とタグ・GitHub の Pull Request から読む。どの設定のキーを参照にするかは設定の契約(`plugins/ndf/contracts/settings/`)の行が持ち、ai-plugins の値を部品に置かない +- **プロジェクトごとに違うもの**: 参照の値は各リポジトリの origin の版とタグ・GitHub の Pull Request・プランの状態ディレクトリから読む。版の規則に要る値(プラグインの名前・チャネル)は参照の宣言の `params` が持ち、ai-plugins の値を部品に置かない +- **参照にしないもの**: `.ndf/` の設定から読んだ値と予算の秒は、今と同じく組んだ時点の値でプランに書く。組んだ時点の設定と流す時点の設定も照らさない(決定 4) - **当たるモード**: `standard`。#2335 の柱 P5 で、柱 P6(#2339 データの契約)・P8(#2341 入力の契約)・P3(#2342 持ち主の記録)のマージの後に実装する ## あるべき姿の根拠 @@ -34,7 +35,7 @@ | コンテキスト | 何の語が 1 つの意味に決まるか | | --- | --- | -| NDF の開発ワークフロー(`ndf-workflow`) | プランの参照・解決の記録・解決の控え・承認時の値・形を決めた設定・プランのハッシュ・版の記録 | +| NDF の開発ワークフロー(`ndf-workflow`) | プランの参照・解決の記録・解決の控え・承認時の値・プランのハッシュ・版の記録 | | リリース(`ndf-release`) | 承認したコミット・承認資料(語の意味は変えない。承認資料に「承認した版」の行を足す) | **リリースが供給者、開発ワークフローが顧客の関係(顧客 / 供給者)。** 参照の部品は承認資料の形(承認したコミットと承認した版の行)と @@ -44,7 +45,7 @@ | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| プラン | プランを組むエントリポイントと人(実行の間は誰も書かない) | プランのファイル | ステップ | 参照の宣言・形を決めた設定・実行条件 | +| プラン | プランを組むエントリポイントと人(実行の間は誰も書かない) | プランのファイル | ステップ | 参照の宣言・実行条件 | | 解決の控え | 参照の部品(`lib/refs.py`) | プランの状態ディレクトリ | 控えの行(参照の名前ごと) | 解決した値・出所・決め直しの履歴 | | 実行 | 実行の核(#2335) | 実行の識別子 | 実行の記録の行 | 解決の記録の行・プランのハッシュ | @@ -56,14 +57,12 @@ | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | | I1 | プラン | プランのファイルは参照の解決で書き換わらない。解決した値はメモリ上のステップにだけ入る | 書き込みの経路が無い(解決の部品はプランのパスを書き口として受け取らない) | -| I2 | 実行 | ステップに入る値は、解決した参照・プランの値(明示)・プランの値(旧い形)のどれかの出所を持ち、1 値 1 行で解決の記録に残る | 記録へ書けなければ `stopped`(#2335 の I3) | -| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、形を決めた設定の照合の後・worktree を作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する。版の決め直しの事前判定も、実行条件の評価と形を決めた設定の照合の後に行い、飛ばす・食い違うプランでは版を照会しない | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | +| I2 | 実行 | 参照からステップに入る値は、解決した参照・控えの値・明示の版・旧い形の差し込みのどれかの出所を持ち、1 値 1 行で解決の記録に残る。参照を通らない値(設定・予算・引数で明示した上限とブランチ)はプランの値そのもので、解決の記録に行を持たない | 記録へ書けなければ `stopped`(#2335 の I3) | +| I3 | 実行 | 参照の解決は、持ち主の記録を取った後・実行条件の評価の後に行う。プランの上位の欄(`作業場所`・`branch`)の参照は、worktree を作る前に 1 回だけ解決する。ステップの参照は、ステップを流す直前に解決する。開始のステップ(`--from`)によらず実行条件を先に評価する。版の決め直しの事前判定も実行条件の評価の後に行い、飛ばすプランでは版を照会しない | 順序はプランの実行の 1 つの関数が持つ(下の処理の流れ) | | I4 | 実行 | 解決できない参照を使うステップは流れない。推定の値・空・`0`・`unknown` で埋めない | `stopped`・終了コード 6、`gaps` に `ref:<名前>` と理由 | | I5 | 実行 | 承認時の値を持つ参照は、解決した値と承認時の値が食い違えば流れない。承認したコミットの後に許したもの(この版の配布の Pull Request)だけが入ったときは食い違いとみなさない | `gate`・終了コード 10(`EXIT_GATE`)、`items` に承認時の値と実行時の値 | | I6 | 解決の控え | 版の参照は控えがあれば控えの版を使う。決め直すのは #2164 の規則に当たるときだけで、前の版と理由を控えの `history` と解決の記録に残す | 規則に当たらない決め直しの経路が無い(決め直しは `release_version` の判定だけが起こす) | -| I7 | プラン | 形を決めた設定の今の値が、プランが控えた値と違えば、プランの最初のステップから流さない | `stopped`・終了コード 2、`gaps` に設定の名前と、`next` に組み直すコマンド | -| I8 | 実行 | 完了したプランを `--from` なしに流し直さない。完了した実行の記録のプランのハッシュと今のハッシュが違えば流さない | `gate`・終了コード 10(`EXIT_GATE`)、`next` に `--from` で流し直すコマンド | -| I9 | 実行 | 秘密と宣言された設定の値は、解決の記録と結果に書かれず、出所の名前だけが残る | 書く前に値を落とす(#2335 の I9 と同じ経路) | +| I7 | 実行 | 完了したプランを `--from` なしに流し直さない。完了した実行の記録のプランのハッシュと今のハッシュが違えば流さない | `gate`・終了コード 10(`EXIT_GATE`)、`next` に `--from` で流し直すコマンド | ### ドメインイベント @@ -89,7 +88,6 @@ | 解決の記録 | 要求の用語のとおり。実行の記録の `ref` 行である | 追加済み(要求) | | 承認時の値 | 要求の用語のとおり | 追加済み(要求) | | 解決の控え | 流し直しでも同じ値を使う参照(版)の解決した値と決め直しの履歴を、プランの状態ディレクトリに残すファイル(`refs.json`) | 追加(`ndf-workflow`) | -| 形を決めた設定 | プランにどのステップを置くかを決めた `.ndf/` の設定の値。組んだ時点の値をプランが控え、流す前に今の値と照らす | 追加(`ndf-workflow`) | | プランのハッシュ | プランの型で読んだ中身を、キーを並べ替えた JSON にして取った sha256。完了したプランが変わったかを測る | 追加(`ndf-workflow`) | | 版の記録 | 解決の控えのうち、版の参照の行 | 意味の変更(`ndf-workflow`) | | 版の差し込み | 前の版の NDF が組んだプランの `{release_version}`・`{release_base}`。移行関数が版の参照へ読み替える | 意味の変更(`ndf-workflow`) | @@ -98,7 +96,7 @@ | # | 機能 | 誰が使うか | | --- | --- | --- | -| F1 | プランを組むエントリポイントが、版・記録の PR・前のプランの結果・設定・予算を参照で書き、形を決めた設定を控える | `new sprint` / `new close`・開発ループ | +| F1 | プランを組むエントリポイントが、版・記録の PR・前のプランの結果を参照で書く | `new sprint` / `new close`・開発ループ | | F2 | ステップの直前に参照を解決し、解決の記録を書いてメモリ上のステップへ値を入れる | プランの実行 | | F3 | 版の参照の解決を控えに残し、流し直しで同じ版を使い、#2164 の規則のときだけ決め直す | 配布のプラン | | F4 | 承認時の値と照らし、食い違えば `gate` で返す | 本番の配布(プランからも手動の起動からも) | @@ -111,19 +109,18 @@ | 要素 | 責務 | 新設 / 変更 | | --- | --- | --- | | 参照の部品(`lib/refs.py`) | 参照の宣言の型・種類の表・`resolve(ctx, plan, step)`。種類ごとの解決を遅れて読み、解決の記録と解決の控えを書く。外へ書くのは控えと記録だけ | 新設 | -| 参照の種類(`lib/ref_kinds/`) | 種類ごとに 1 モジュール(`version`・`record_pr`・`plan_result`・`setting`・`budget`・`tip`)。値か失敗の理由を返し、終了コードと出力を持たない | 新設(`queue_fill.py` の 5 つの関数と `queue.fill_queue_prs` を移す) | +| 参照の種類(`lib/ref_kinds/`) | 種類ごとに 1 モジュール(`version`・`record_pr`・`plan_result`・`tip`)。値か失敗の理由を返し、終了コードと出力を持たない | 新設(`queue_fill.py` の 5 つの関数と `queue.fill_queue_prs` を移す) | | 版の判定(`lib/release_version.py`) | 次に空いている版・既出の判定・自分の配布のマージの有無(変えない) | 変えない | | 承認したコミット(`lib/approved_commit.py`) | 「承認した版」の行の読み書きを足す。`release_lib/approval.py` の `check_approved`・`release_pr_allowed` を移し、`tip` の種類と配布の本番のエントリポイントが同じ関数を呼ぶ | 変更 | | 承認資料を書くエントリポイント(`release-steps.py approval-facts`) | 承認資料に「承認した版」の行を書く | 変更 | -| プランの型(柱 P6 の `lib/model/` のプラン) | `refs`(参照の宣言)・`pins`(形を決めた設定)の欄を持つ。旧い差し込みを参照へ読み替える移行関数を持つ | 変更(柱 P6 の型に欄を足す) | +| プランの型(柱 P6 の `lib/model/` のプラン) | `refs`(参照の宣言)の欄を持つ。旧い差し込みを参照へ読み替える移行関数を持つ | 変更(柱 P6 の型に欄を足す) | | 解決の控えの型(`lib/model/` の `refs-store`) | 控えの形と、`version.json` を控えへ読み替える移行関数 | 新設 | -| プランの実行(`supervise_lib/engine.py`) | ステップの直前に実行条件 → 形を決めた設定 → 参照の解決の順に呼ぶ。開始のステップで実行条件を飛ばさない | 変更 | +| プランの実行(`supervise_lib/engine.py`) | ステップの直前に実行条件 → 参照の解決の順に呼ぶ。開始のステップで実行条件を飛ばさない | 変更 | | キュー(`supervise_lib/queue.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す) | | 差し込みの解決(`supervise_lib/queue_fill.py`) | — | 削除(`FINISHED`・`named`・`results_named` は `ref_kinds/plan_result.py` へ移す) | | 再開(`supervise_lib/queue_resume.py`・柱 P3 の `supervise.py resume`) | 完了したプランをプランのハッシュで照らし、違えば `gate` で返す | 変更 | -| プランを組むエントリポイント(`supervise_lib/templates.py`・`release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`verify_steps.py`・`decl.py`) | 値の代わりに参照と形を決めた設定を書く。引数で明示した上限とブランチは値のまま、明示した版は版の参照の `explicit` に書く | 変更 | +| プランを組むエントリポイント(`supervise_lib/release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`sprint.py`・`sprint_waves.py`・`design_merge.py`、`lib/parallel_release.py`。差し込みを書く 7 ファイル) | 差し込みの代わりに参照を書く。明示した版は版の参照の `explicit` に書く。設定・予算・引数で明示した上限とブランチは今と同じく値で書く | 変更 | | 開発ループ(`lib/sprint_loop_cli.py`・`lib/sprint_loop.py`) | `--dev` を任意にし、省いたら `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない | 変更 | -| 設定の契約(`plugins/ndf/contracts/settings/`)と読み手(`scripts/contracts_lib/model.py`) | キーの行に `plan`(`ref` / `budget` / `shape`)と `secret` を足す。`supervise.json` の `impl_timeout`・`refactor_budget_minutes` の行を足す | 変更 | | エントリポイントの契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番のエントリポイントの行に `refs`(`tip` の参照)を足す | 変更 | ### 要素の関係 @@ -139,7 +136,6 @@ graph TD RF --> RK[参照の種類] RK --> RV[版の判定] RK --> AC[承認したコミット] - RK --> SC[設定の契約] RF -->|書く| ST[解決の控え] RF -->|追記| LG[実行の記録] AF[承認資料を書くエントリポイント] --> AC @@ -159,21 +155,21 @@ graph TD ```text plugins/ndf/ ├── contracts/ -│ ├── settings/*.json # 変更: キーの行に plan・secret │ └── entrypoints/*.json # 変更: 配布の本番のエントリポイントに refs └── scripts/ ├── lib/ │ ├── refs.py # 新設 - │ ├── ref_kinds/ # 新設: version・record_pr・plan_result・setting・budget・tip - │ ├── model/ # 変更(柱 P6): プランの refs・pins、refs-store + │ ├── ref_kinds/ # 新設: version・record_pr・plan_result・tip + │ ├── model/ # 変更(柱 P6): プランの refs、refs-store │ ├── approved_commit.py # 変更 │ ├── sprint_loop.py / sprint_loop_cli.py # 変更 + │ ├── parallel_release.py # 変更: 差し込みの代わりに参照 │ └── release_version.py # 変えない ├── release_lib/approval.py # 変更: check_approved を approved_commit へ移す └── supervise_lib/ ├── engine.py / queue.py / queue_resume.py # 変更 ├── queue_fill.py # 削除 - └── templates.py ほかプランを組む 6 ファイル # 変更 + └── release_templates.py ほかプランを組む 6 ファイル # 変更 ``` ## 構造 @@ -191,7 +187,6 @@ classDiagram +name +value +source - +secret +approved_value } class RefFailure { @@ -211,9 +206,8 @@ classDiagram RefSpec ..> StoreRow : sticky なら読み書き ``` -- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。形を決めた設定の照合の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「流れの決まり」) +- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。実行条件の評価の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「流れの決まり」) - **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` -- **`check_pins(ctx, plan) -> RefFailure | None`**: 形を決めた設定を今の値と照らす。プランの最初に流すステップの前に 1 回だけ呼ぶ - **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る - **種類のモジュールの形**: `resolve(ctx, spec, plan) -> Resolution | RefFailure`。GitHub と origin を読む種類は、その種類を使うステップでだけ読み込まれる @@ -227,47 +221,21 @@ classDiagram | --- | --- | | ステップの文字列の中 | `{ref:<名前>}`(版の起点は `{ref:<名前>.base}`) | | プランの上位の欄(`作業場所`・`branch`) | `{ref:<名前>}`。版を省いた配布のプランは `branch` に `release/v{ref:version}` を、`作業場所` に版を含むパスを持つ | -| 数の欄(`timeout`) | `{"ref": "<名前>"}` | | 種類 | `params` | 解決した値 | `sticky` | 解決できないとき | | --- | --- | --- | --- | --- | | `version` | `plugin`・`channel`(`dev` / `prod`)・`level`・`series`(本番が版を読む開発版のプランの名前)・`explicit`(明示の版。任意) | `{version, base}` | 真 | origin の版かタグを読めない・明示の版が既出・系列の版の記録が無い(#2164 の `unreadable` / `taken` / `no_series`) | | `record_pr` | `issues`(課題の番号の並び) | リリース記録を置いた PR の番号 | 偽 | 記録の PR が見つからない(`record_find` の理由) | | `plan_result` | `plan`(名前)か `plans: "earlier"`(キューの前のステージ)・`field`(`pr` / `state` / `prs` / `approved`)・`optional` | PR の番号・状態ディレクトリのパス・PR の並び・承認したコミット | 偽 | 名前の一致するプランの完了した結果が無い(`optional` の `state` だけ空を返す) | -| `setting` | `setting`(設定の契約の id とキー。例 `pace.json:auto.verify`) | 設定の値 | 偽 | 設定のファイルかキーが無い・型が違う | -| `budget` | `setting`(任意)・`policy`(上限の宣言の方針の名前)・`unit`(`seconds` / `minutes`)・`margin` | 秒(`margin` が真なら `with_margin` を足す) | 偽 | 設定が型に合わない・方針が無い(`LimitsError`) | | `tip` | `branch`・`approved_from`(承認したコミットを返す参照の名前)・`allowed_pr`(許すものの参照の名前) | ブランチの先端の SHA | 偽 | 先端を読めない | `approved`(任意)は承認時の値を持つ参照に付け、`gate-2` を書く。`version` では承認資料の「承認した版」と、`tip` では承認したコミットと `compare_approved` で照らす。承認資料に「承認した版」の行が無い旧い資料では、#2164 の `series_changed`(承認ゲート 2 の通過の後に系列の版の記録が変わった)で照らす。 -**引数で明示した上限とブランチは参照にしない。** 次の引数を渡したときは値のまま書く。解決の記録の出所は「プランの値(明示)」になる(決定 5)。 -対象の引数は `--impl-timeout`・`--refactor-budget-minutes`・`--base`・`--production-branch` である。 +**設定の値・予算の秒・引数で明示した上限とブランチは参照にしない。** プランを組むエントリポイントが今と同じく値で書く(決定 4)。 **`--version` で明示した版は、版の参照の `params.explicit` に値のまま入れる。** 参照の部品は明示の版を置き換えず、版の参照の解決で既出の判定(#2164 の `taken`。既出なら `stopped`・終了コード 6)と、`approved` を付けた参照では承認した版との照合を必ず通す。解決の記録の出所は `plan:explicit`、控えの `how` は `explicit` になる(決定 5)。 -### 参照にする設定のキー - -設定の契約のキーの行の `plan` が、この表を正として持つ。設計の表は契約からコピーした一覧で、食い違えば契約が正である。 -表は `supervise_lib/decl.py` の `apply_decls` と `sprint_routes.py` が読む項目から作った(2026-11-04 の develop)。 - -| 設定 | キー | ステップのどこに入るか | `plan` | -| --- | --- | --- | --- | -| `pace.json` | `<進め方>.verify` | 導入の確認のコマンド(`sprint_routes.pace_verify`) | `ref` | -| `supervise.json` | `impl_timeout` | 実装のステップの `timeout` | `budget` | -| `supervise.json` | `refactor_budget_minutes` | リファクタリングのステップの `timeout` と `--budget-minutes`(`templates._inspect_steps`) | `budget` | -| `project.json` | `test_duration` | テストと CI の待ちの `timeout`(`verify_steps.plan_limits`) | `budget` | -| `worktree.json` | `base_branch` | 起点・宛先(ステップの数は変えないが、変わればスプリントの宛先が変わる) | `shape` | -| `worktree.json` | `production_branch` | 承認ゲート 2 と本番のステップを置くか | `shape` | -| `supervise.json` | `release` | 配布のテンプレート | `shape` | -| `supervise.json` | `sync_checks`・`format` | sync と format のステップを置くか | `shape` | -| `supervise.json` | `test` | テストのステップ(`project.json` に `test` が無いとき) | `shape` | -| `project.json` | `test`・`delivery` | テストのステップ・配布の経路 | `shape` | -| `pace.json` | `<進め方>.enabled` | 経路を置くか | `shape` | - -**形を決めた設定(`pins`)** は `{setting, digest}` の並びで、`digest` は組んだ時点の値を正規化した JSON の sha256 である。値そのものは持たない -(秘密の値をプランへ書かないため)。今の値が無いことも「違う」として扱う。 - ### 解決の控え(`<状態ディレクトリ>/refs.json`) | キー | 型 | 空を許すか | 意味 | @@ -288,10 +256,10 @@ classDiagram | キー | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | -| `step` | str | 許さない | ステップの id。形を決めた設定の照合は `pins` | +| `step` | str | 許さない | ステップの id。上位の欄の解決は `head` | | `name` / `kind` | str | 許さない | 参照の名前と種類。プランの値は `kind: "literal"` | | `source` | str | 許さない | `ref`(解決した)/ `store`(控えの値)/ `plan:explicit` / `plan:legacy` | -| `value` | any | 許す | 解決した値。秘密の設定はキーごと書かない(I9) | +| `value` | any | 許す | 解決した値 | | `outcome` | str | 許さない | `resolved` / `redecided` / `unresolved` / `mismatch` / `match` | | `approved` | object | 許す | 承認時の値(照らしたときだけ) | | `reason` | str | 許す | 解決できない・食い違う・決め直した理由 | @@ -319,7 +287,6 @@ classDiagram | --- | --- | | `sprint-state.py loop sprint` | `--dev` を任意にする。渡せば今と同じく `new sprint --version <開発版>` を組む。省けば `--version` を渡さず、周回の `versions` は配布の答え(`settle_release`)で埋まる | | 解決できないときの結果 | ステップを流さず、プランの結果は「止まった」、結果 JSON は `status: "stopped"`・終了コード 6、`gaps` に `ref:<名前>`、`summary` に参照の名前と理由 | -| 形を決めた設定が違うときの結果 | `status: "stopped"`・終了コード 2、`gaps` に `pin:<設定>`、`next` に同じ引数で組み直す `supervise.py new` のコマンド | | 承認時の値と食い違うときの結果 | `status: "gate"`・終了コード 10(`EXIT_GATE`)、`items` に `{kind: "ref-mismatch", name, approved, actual, reason}` | | 完了したプランが変わったときの結果 | 再開とキューの `--resume` は `status: "gate"`・終了コード 10(`EXIT_GATE`)、`items` に `{kind: "plan-changed", plan, digest_done, digest_now}`、`next` に `supervise.py resume <実行の識別子> --from <プラン>=<ステップ>` | | キューの done | 参照で止まったプランの項目は `result: "止まった"`・`unfilled: true`・`reason`。旧い版の `result: "流さなかった"`・`unfilled: true` の項目も読む | @@ -341,10 +308,6 @@ sequenceDiagram alt 飛ばす E-->>C: 完了(実行条件に当たらない) end - E->>F: check_pins(最初のステップの前) - alt 形を決めた設定が違う - E-->>C: stopped・2 - end E->>F: resolve_head(作業場所・branch) alt 解決できない・食い違う F-->>E: RefFailure @@ -391,7 +354,7 @@ sequenceDiagram | --- | --- | | 版の判定と承認したコミット | 参照の種類の中で呼ぶ | | プランの型と解決の控えの型 | 読み書きの型 | -| プランを組むエントリポイント・開発ループ・設定の契約 | 流す前の E1 で働く | +| プランを組むエントリポイント・開発ループ | 流す前の E1 で働く | | 承認資料を書くエントリポイント | E2 で働く | | エントリポイントの契約と配布の本番のエントリポイント | 下の「流れの決まり」の 2 つ目の項目 | | 差し込みの解決 | 消す | @@ -412,12 +375,11 @@ sequenceDiagram 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。 - **決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の 1 の退避では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める 1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらも状態ディレクトリの控えから世代を読んで `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない - 2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動が照合を通ったことは根拠にせず、その起動の実行条件と設定の値で確かめる + 2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動の判定は根拠にせず、その起動の実行条件で確かめる 1. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない - 2. 形を決めた設定を照合する(I7 と同じ `check_pins`)。食い違えば、版を照会せず、判定も退避もしない - 3. 上の判定(`taken` → `own_released`)を流す。当たれば上の 1〜3 を行ってから接頭辞を決める + 2. 上の判定(`taken` → `own_released`)を流す。当たれば上の 1〜3 を行ってから接頭辞を決める - 飛ばす・食い違うプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 と I7 の照合が同じ結果(完了・`stopped`・終了コード 2)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである + 飛ばすプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 が同じ結果(完了)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューは接頭辞を耐久ステップ `admit_step` の中で決め、キューの再開(`launch_queue` の `keep=None`)ではその出力が再生されて旧世代の接頭辞が使われる。そのため、2 の事前判定で 1 つでも決め直したプランがあれば、`launch_queue` を `fresh` で開く。途中の耐久ワークフローは子ごと止まり、`admit_step` が世代の増えた接頭辞を計算し直す。完了したプランは、完了の記録が接頭辞で引けるため流し直されない。決め直しが無ければ、これまでどおり続きから再開する 4. 耐久ワークフローの中の `resolve_head` は、決め直しを起こさない。控えの版をそのまま使う(控えが無いときの最初の決定を除く)。決め直しの経路は 2 の判定の 1 つだけになる @@ -447,8 +409,8 @@ stateDiagram-v2 | 性能・拡張性 | 参照を持たないエントリポイントの起動に足す時間は #2335 の上限 150 ms の内側。GitHub と origin はその参照を持つステップだけが読む | 核はエントリポイントの契約に `refs` が無ければ参照の部品を読み込まない。種類のモジュールは使う種類だけを読み込む | 参照を持たないエントリポイントの `--help` を前後で 10 回ずつ測り、差の中央値を実装の Pull Request に書く。GitHub を呼ぶ関数を差し替え、参照を持たないプランで呼ばれないことをテストで見る | | 可用性 | 解決できない・食い違うときは待ち続けずに返る | 種類のモジュールは待ちを持たず、外部の呼び出しは上限の部品(柱 P4)の 1 回の上限で打ち切る | 解決できない・食い違うテストが `stopped` / `gate` で返る | | 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを実行の識別子つきで書く | `ref` 行の `outcome` に 5 つの値 | 種類ごとのテストで `ref` 行の `outcome` を見る | -| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)とエントリポイントの移し(プランを組むエントリポイント・開発ループ・設定の契約)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | -| セキュリティ | 秘密と宣言された入力は値を書かない。C1・C2 を変えない | 設定の契約の `secret` が真のキーは、`ref` 行の `value` を書かず、`pins` は digest だけを持つ | 秘密と宣言したキーを参照にしたテスト | +| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)とエントリポイントの移し(プランを組むエントリポイント・開発ループ)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | +| セキュリティ | 参照が解決する値は秘密を含まない。C1・C2 を変えない | 種類は `version`・`record_pr`・`plan_result`・`tip` の 4 つで、版・PR の番号・状態ディレクトリのパス・コミットの SHA だけを返す。設定と秘密を読む種類を持たない | 種類の表(データ構造の節)を設計レビューで見る | | システム環境 | 版の比較は `versions`(extras)。無い環境では `stopped`・69 | `version` の種類だけが `versions` を読み込み、読めなければ `deps` の判定で返す | extras を外した環境で版の参照を持つプランを流すテスト | ## 決定の記録 @@ -459,29 +421,27 @@ stateDiagram-v2 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | -| 1 | 版を明示しない `new sprint`・`new close`・`loop sprint` が組むプランのファイルに、版数・記録の PR の番号・上の表の `ref` / `budget` の値・予算の秒が無く、`refs` と `pins` がある | 組む時点に `with_margin` の秒を書く/`pace_verify` の値を書く | +| 1 | 版を明示しない `new sprint`・`new close`・`loop sprint` が組むプランのファイルに、版数と記録の PR の番号が無く `refs` がある。設定の値と予算の秒は今と同じ値で入る | 組む時点に版を書く/記録の PR の番号を書く/旧い差し込み(`{release_version}`・`{queue_record}`)を書く | | 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | -| 3・I4 | 記録の PR が無い・origin を読めない・設定のキーが無い・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | +| 3・I4 | 記録の PR が無い・origin を読めない・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | | 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | | 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で、世代の増えた新しい耐久ワークフローとして最初のステップから流れる。決め直した後に落ちて打ち直しても、旧版の耐久ステップの記録を引かない。キューを `--resume` で再開して決め直しが起きても、`admit_step` が世代の増えた接頭辞で流す | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する/耐久ワークフローの接頭辞に世代を入れない/キューの再開で `admit_step` の旧い出力を再生する | -| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。控えに版の行があるプランも、実行条件で飛ぶ・形を決めた設定が食い違う起動では、決め直しの事前判定で版を照会せず、状態ディレクトリを退避しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/事前判定を実行条件か形を決めた設定の照合より先に流す/上位の欄を解決せずに worktree を作る | -| 7・I8 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | -| I7 | 組んだ後に `production_branch` か `release` を変えると最初のステップが流れず、`stopped`・2 と組み直すコマンド | 形を決めた設定を照らさない | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。控えに版の行があるプランも、実行条件で飛ぶ起動では、決め直しの事前判定で版を照会せず、状態ディレクトリを退避しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/事前判定を実行条件より先に流す/上位の欄を解決せずに worktree を作る | +| 7・I7 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | | 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば版の参照の `explicit` に入り、既出の版なら `stopped`・6 | `--dev` を省いたときに既定の版を入れる/明示の版をリテラルで書いて既出の判定を飛ばす | | 9 | #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件の既存のテストが通る。`supervise_lib/queue_fill.py` が無く、`queue.py` に `fill_` で始まる関数が無い | 差し込みの関数をキューに残して二重に解決する | | 10・F7 | 前の版が組んだプラン(差し込みのもの・値を書き込んだもの)と `version.json` を持つ状態ディレクトリで流れ、`ref` 行の `source` が `plan:legacy` / `store` | 移行関数を外す/旧い差し込みを文字のまま流す | -| 11・I9 | 秘密と宣言したキーを参照にすると、記録と結果に値が無く出所の名前だけがある | `secret` を見ずに `value` を書く | -| 12 | (手動)子の課題 4 件の再現の手順を本番へ出した版で通し、課題の本文の表に結果を書く | — | -| 13 | 参照を持たないエントリポイントとプランの振る舞いと結果が前と同じ。全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | 参照を持たないプランでも参照の部品が GitHub を読む | +| 11 | (手動)子の課題 4 件の再現の手順を本番へ出した版で通し、課題の本文の表に結果を書く | — | +| 12 | 参照を持たないエントリポイントとプランの振る舞いと結果が前と同じ。全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が前後で通る | 参照を持たないプランでも参照の部品が GitHub を読む | ## 設計の結果 | 課題 | 扱い | 取り込み先 | 触るファイル | | --- | --- | --- | --- | -| #2344 | 実装する | — | `plugins/ndf/scripts/lib/refs.py`、`plugins/ndf/scripts/lib/ref_kinds/`、`plugins/ndf/scripts/lib/model/`、`plugins/ndf/scripts/lib/approved_commit.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/release_lib/approval.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/contracts/settings/`、`plugins/ndf/contracts/entrypoints/`、`scripts/contracts_lib/model.py`、`docs/glossary/glossary.json`、`docs/glossary.md` | +| #2344 | 実装する | — | `plugins/ndf/scripts/lib/refs.py`、`plugins/ndf/scripts/lib/ref_kinds/`、`plugins/ndf/scripts/lib/model/`、`plugins/ndf/scripts/lib/approved_commit.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/release_lib/approval.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/scripts/lib/parallel_release.py`、`plugins/ndf/contracts/entrypoints/`、`docs/glossary/glossary.json`、`docs/glossary.md` | 子の課題 4 件(#2230・#2164・#2349・#2354)は表に載せない。表の行はスプリントの Pull Request で閉じる。 -子の課題は、本番へ出した版で再現の手順を通してから、閉じるか残すかを決める(受け入れ条件 12)。 +子の課題は、本番へ出した版で再現の手順を通してから、閉じるか残すかを決める(受け入れ条件 11)。 #2349 の 3 つ目の事象は、差分の無い実装のプランで `pr` が終了コード 1 になることである。 これは柱 P1(#2337)の結果の契約の問題で、この柱では直さない(要求の「含まない」)。 @@ -490,9 +450,7 @@ stateDiagram-v2 | 項目 | 内容 | | --- | --- | | 柱 P3・P6・P8 の設計 | 2026-11-04 の時点で P6(#2339)と P8(#2341)の設計文書は無く、P3(#2342)は設計 Pull Request 2480 が開いた状態である。この設計はそれらの語(`ctx.resolve`・`ctx.log`・プランの型・入力の宣言・`supervise.py resume`)にだけ依る。形が変わったら実装の開始時に読み直す(要求の未決 5) | -| 上限の方針の名前 | `budget` の種類が引く方針の名前は柱 P4(#2343)の上限の宣言が決める。この設計は名前を決めない | | プランのキーの名前 | #2335 の決定 4 でプランの日本語のキーは英語へそろう。この文書の `実行の条件`・`版` などは今の名前で書いた。英語の名前は柱 P6 が決める | -| 形を決めた設定の範囲 | `base_branch` を `shape` に置いたのは、宛先が変わればスプリントの PR の向きが変わるためである。値だけを差し替えて流してよいかは、実装の前に `new sprint` が `base_branch` を書く箇所を数えて確かめる | | 承認した版の行の書き手 | 承認資料を書く `approval-facts` が、配布の本番のプランの版の参照をどの時点で読むか(承認ゲート 2 の提示の前に版が決まっていること)は、提示のプランの流れで確かめる | -| 設定のキーの一覧の網羅 | 表は `apply_decls` と `sprint_routes` の読む項目から作った。ほかのプランを組む関数が設定を読んでステップへ書いていないかは、実装の開始時に `read_decl` と `read_pace` の呼び出し元を数えて確かめる | +| 差し込みを書くエントリポイントの網羅 | 構成要素の 7 ファイルは、`plugins/ndf/scripts/supervise_lib/` と `lib/` を差し込みの書き方(`{release_version}`・`{release_base}`・`{queue_record}`・`{queue_pr`・`{queue_state`・`{queue_approved`・`QUEUE_PRS`)で検索して当たったもののうち、プランを組むものである(2026-11-05 の develop)。`plugins/ndf/scripts/` 直下のエントリポイントがプランを組んで差し込みを書いていないかは、実装の開始時に同じ検索で確かめる | | 並行するスプリントの版の取り合い | 2 つのスプリントが同時に次に空いている版を決めたときは、#2164 の `own_released` の確かめで後から決め直す。版の参照を持ち主の記録で排他にするかは測っていない(#2354 の起票者の推定 2 点目) | diff --git a/issues/issue-2344-requirements.md b/issues/issue-2344-requirements.md index f6347953d..fdf5c17dd 100644 --- a/issues/issue-2344-requirements.md +++ b/issues/issue-2344-requirements.md @@ -36,7 +36,12 @@ > 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 -**解釈**: 利用者が求めているのは、プランを作った時点の値が流す時点で古くなって止まる型の不具合(大方針の原因の型 E「解決の時点」。版・記録の PR・宣言・予算)を、1 件ずつではなく仕組みで無くすことである。今は #2164 の版の差し込み(`{release_version}`)と #2230 の記録の PR の差し込み(`{queue_record}`)が、キューの中でそれぞれ別の手当てとして解決している。この柱は、その 2 つを実行の核の参照の部品(#2335 の設計の `lib/refs.py`)へ寄せ、ほかの値にも同じ形を当てる。 +範囲の指示(書き手: 利用者、日付: 2026-11-05。設計の承認ゲート 1 の答え): + +> 範囲を、起きた不具合に根拠のあるものに絞る: 版数・リリース記録の PR の直前の解決(#2164)・実行条件を先に評価(#2349)・流し直しの判定(#2349)・開発ループの版(#2354)。 +> 外すもの: 設定 11 キーと予算を同じ参照の仕組みに載せる部分と、形を決めた設定のハッシュ照合(受け入れ条件 1・11 も合わせて縮める)。 + +**解釈**: 利用者が求めているのは、プランを作った時点の値が流す時点で古くなって止まる型の不具合(大方針の原因の型 E「解決の時点」)のうち、起きた不具合に根拠のあるもの(版数・記録の PR・実行条件の評価・完了したプランの流し直し・開発ループの版)を、1 件ずつではなく仕組みで無くすことである。今は #2164 の版の差し込み(`{release_version}`)と #2230 の記録の PR の差し込み(`{queue_record}`)が、キューの中でそれぞれ別の手当てとして解決している。この柱は、その 2 つを実行の核の参照の部品(#2335 の設計の `lib/refs.py`)へ寄せ、同じキューの中で解決している前のプランの結果の差し込みと承認したコミットの照合も同じ形に載せる。設定の値と予算は参照にしない(利用者の範囲の指示)。 ## 起票者の推定 @@ -44,10 +49,10 @@ | # | 条件 | 推した出所 | この要求での扱い | | --- | --- | --- | --- | -| S1 | プランは版・記録の PR・宣言の値を、値でなく参照で持つ | 起票時の本文の受け入れ条件 1・#2335 の受け入れ条件 6 | 受け入れ条件 1 | +| S1 | プランは版・記録の PR・宣言の値を、値でなく参照で持つ | 起票時の本文の受け入れ条件 1・#2335 の受け入れ条件 6 | 受け入れ条件 1。版と記録の PR だけを参照にし、宣言の値は含まない(利用者の範囲の指示、2026-11-05) | | S2 | 実行の核がステップの直前(持ち主の記録を取った後)に参照を解決し、解決した値を記録する。解決できなければ流さずに止まる | 起票時の本文・#2335 の設計の不変条件 I14・ドメインイベント E5 | 受け入れ条件 2・3 | | S3 | 承認ゲートで見た値(先端・版)と実行時の値の食い違いを核が照合し、食い違えば止まって人へ戻す | 起票時の本文の受け入れ条件 2 | 受け入れ条件 4 | -| S4 | 予算も参照で持つ | 起票時の本文「何をするか」・#2335 の原因の型 E の説明 | 受け入れ条件 1。値は柱 P4(#2343)の上限の宣言から引く(前提 4) | +| S4 | 予算も参照で持つ | 起票時の本文「何をするか」・#2335 の原因の型 E の説明 | 含まない(利用者の範囲の指示、2026-11-05)。予算は今と同じく、プランを組む時点の秒で持つ | | S5 | 触るファイルも参照で持つ | 起票時の本文「何をするか」 | 含まない。触るファイルは、プランを書いた側が宣言する「触る予定の範囲」で、プランの外の状態が変わって古くなる値ではない。触るファイルが絡む課題(#1811・#2037・#2165)は重なりの判定の粒度の問題で、柱 P3(#2342)が残す見込みとしている | | S6 | 解決した値はプランのファイルへ書き戻さず、実行の記録にだけ残す(今のキューは差し込みを埋めたプランでファイルを上書きする) | #2349 の「完了の後にプランの JSON が変わったら流し直す」事象。プランを上書きすると、プランが変わったのが人の編集か解決かを区別できない | 受け入れ条件 2。誤ると、解決の記録と流し直しの判定(受け入れ条件 7)を作り直すことになるため、問わずにこの案を選んだ | | S7 | 実行条件(プランの `実行の条件`)も、ステップの直前に評価する値として参照と同じ段で扱い、`--from` で始めても先に評価する | #2349 の「直したいこと」1 | 受け入れ条件 6 | @@ -55,7 +60,7 @@ ## 目的 -- プランを作ってから流すまでの間に版・記録の PR・宣言・予算が変わっても、プランを作り直さずに流せる状態にする(今は #2164 の 2 回の再発のように、conductor がプランを作り直すか版を書き換えた) +- プランを作ってから流すまでの間に版・記録の PR が変わっても、プランを作り直さずに流せる状態にする(今は #2164 の 2 回の再発のように、conductor がプランを作り直すか版を書き換えた) - 参照を解決できないとき・承認ゲートで人が見た値と食い違うときに、推定の値や古い値で黙って進まず、理由を添えて止まる状態にする - 参照を解決する手当てを 1 か所にまとめ、新しい種類の値を足しても同じ規則で解決・記録・照合される状態にする(今は版・記録の PR・前のプランの結果が、キューの中で別々の関数として解決している) @@ -68,16 +73,14 @@ - 記録の PR の差し込み(`{queue_record}`。`supervise_lib/queue_fill.py` の `_fill_record`) - 前のプランの結果の差し込み(`{queue_pr:<名>}`・`{queue_state:<名>}`・`{queue_prs:<名>}`・`{queue_approved:<名>}`・`QUEUE_PRS`) - 承認したコミットと先端の比較(`lib/approved_commit.py` の `compare_approved`。#815) -- 前提 4: 予算の参照は、上限の宣言(柱 P4 #2343 の `plugins/ndf/contracts/limits.json`)の方針の名前を指す。値はこの柱では決めず、上限の部品が返す。今の予算はプランを作る時点に秒へ直して `timeout` に書き込んでいる(`supervise_lib/templates.py` の `_inspect_steps`) -- 前提 5: 承認したコミットとの照合は、今の `compare_approved` と同じく「承認したコミットの後に、許したもの(この版の配布の Pull Request)だけが入っている」なら食い違いとみなさない -- 前提 6: 参照を持たない旧い形のプラン(この柱より前の版の NDF が作り、値を書き込んだプラン)は、書き込まれた値をそのまま使って流す。実行の記録にはその値の出所を「プランの値」と書く(#2335 の移行性。旧い版が書いた状態ファイルを読む受け入れ条件 10b と同じ扱い) +- 前提 4: 承認したコミットとの照合は、今の `compare_approved` と同じく「承認したコミットの後に、許したもの(この版の配布の Pull Request)だけが入っている」なら食い違いとみなさない +- 前提 5: 参照を持たない旧い形のプラン(この柱より前の版の NDF が作り、値を書き込んだプラン)は、書き込まれた値をそのまま使って流す。実行の記録にはその値の出所を「プランの値」と書く(#2335 の移行性。旧い版が書いた状態ファイルを読む受け入れ条件 10b と同じ扱い) ## 対象範囲 含む: - 参照の部品と、実行の核の参照の段(E4〜E7) - 前提 3 の 4 つの実装を参照の部品へ寄せること(キューに残る差し込みの関数を消す) -- 宣言の値(`.ndf/` の設定)と予算を、プランを作る時点の値でなく参照で持たせること。対象のキーは設計が契約の一覧(`plugins/ndf/contracts/settings/`)から一覧にする - 実行条件を、`--from` で始めるときも含めてステップの直前に評価すること(#2349 の 1 つ目) - 完了したプランの流し直しの判定を、解決の記録とプランの中身の照合で行うこと(#2349 の 2 つ目) - 開発ループが組むスプリントの版を参照にすること(#2354) @@ -86,7 +89,8 @@ 含まない: - 子の課題を 1 件ずつ直す Pull Request(即時修正の 4 条件を満たすものと本番を止めているものは除く) - 触るファイルを参照にすること(起票者の推定 S5) -- 差分の無い実装のプランで `pr` が終了コード 1 になること(#2349 の 3 つ目)。値の解決の時点の問題でなく、結果の契約(柱 P1 #2337)の問題である(受け入れ条件 12 で確かめて残す) +- 宣言の値(`.ndf/` の設定)と予算を参照にすること、プランを組んだ時点の設定の値と流す時点の値を照らすこと(利用者の範囲の指示)。どちらも今と同じく、プランを組む時点の値で持つ +- 差分の無い実装のプランで `pr` が終了コード 1 になること(#2349 の 3 つ目)。値の解決の時点の問題でなく、結果の契約(柱 P1 #2337)の問題である(受け入れ条件 11 で確かめて残す) - 上限と再試行の値そのもの(柱 P4 #2343)と、持ち主の記録の取り方(柱 P3 #2342) - 版の規則(次に空いている版の決め方・決め直しの条件)を変えること。#2164 の規則をそのまま使う - `factory/` のプラン @@ -101,19 +105,19 @@ | E2 | 承認ゲートで人が見た値(承認したコミット・版)を承認の証跡に記録した | 承認ゲート 1・2 の承認 | 記録できない → 承認を成立させない(今の `approved_commit.record` と同じ) | E1 | — | | E3 | ステップの起動で持ち主の記録を取った | キュー・`supervise.py run`・再開の入口 | 柱 P3 の扱い | E2(承認ゲートの後のステップ) | E4 | | E4 | 実行条件を評価した | E3 | 条件のコマンドが判定できない → `stopped`。飛ばす条件に当たる → 参照を解決せずに完了 | E3。`--from` の有無によらない | E5 | -| E5 | 参照を解決した | E4 で流すと決まった | 解決できない(記録の PR が無い・版を読めない・宣言のキーが無い・前のプランの結果が無い)→ ステップを流さず `stopped` | E3(並行する実行と同じ版を取り合わない) | E5 | +| E5 | 参照を解決した | E4 で流すと決まった | 解決できない(記録の PR が無い・版を読めない・前のプランの結果が無い)→ ステップを流さず `stopped` | E3(並行する実行と同じ版を取り合わない) | E5 | | E6 | 解決した値を承認ゲートで見た値と照らした | E5 で、E2 の記録がある参照 | 食い違う → `gate`(人へ戻す) | E2・E5 | E5 | | E7 | 解決した値と出所を実行の記録へ書いた | E5・E6 | 書けない → `stopped`(#2335 の I3) | E6 | E5 | | E8 | ステップを流した | E7 | 柱 P1 の扱い | E7 | E7 | | E9 | 同じプランを流し直し・再開した | 再開の入口・キューの `--resume` | プランの中身が前の実行の記録と違う、かつ前の実行が完了 → 流さずに `gate` | 前の実行の記録がある | E13 | -洗い出しで移したもの: E1 で参照の形を決める型はプランの型(柱 P6)に置いた(前提 2)。E5 の「流し直しで前に解決した値を使い続けるか」は受け入れ条件 5 に置いた。E6 の「食い違いとみなさない差」は前提 5 に置いた。E9 の「プランの中身の同一性を何で測るか」は未決に置いた。 +洗い出しで移したもの: E1 で参照の形を決める型はプランの型(柱 P6)に置いた(前提 2)。E5 の「流し直しで前に解決した値を使い続けるか」は受け入れ条件 5 に置いた。E6 の「食い違いとみなさない差」は前提 4 に置いた。E9 の「プランの中身の同一性を何で測るか」は未決に置いた。 ## 用語 | 用語 | 意味 | | --- | --- | -| プランの参照 | プランが値の代わりに持つ、値の取り先の名前(例: 次に空いている開発版・この課題のリリース記録を置いた PR・`.ndf/pace.json` の 1 つのキー・上限の宣言の 1 つの方針)。流す直前に実行の核が値へ解決する。この文書で単に「参照」と書くときはこの意味である | +| プランの参照 | プランが値の代わりに持つ、値の取り先の名前(例: 次に空いている開発版・この課題のリリース記録を置いた PR・前のプランが出した PR・承認したコミットと照らすブランチの先端)。流す直前に実行の核が値へ解決する。この文書で単に「参照」と書くときはこの意味である | | 解決の記録 | 1 回の実行が解決した参照ごとの値と出所。実行の記録(#2335 の P2)の行として残る | | 承認時の値 | 承認ゲートで人(か MVV 判定)が承認資料で見た値。承認したコミットと版を含む | | 版の差し込み | 配布のプランが具体的な版の代わりに持つ `{release_version}`・`{release_base}`。この柱で参照の 1 種類になる | @@ -125,23 +129,22 @@ ## 受け入れ条件 -- [ ] 1. `supervise.py new sprint`(版を明示しない)・`new close`・`sprint-state.py loop sprint` が組むプランのファイルは、版数・記録の PR の番号・宣言から読んだ値(設計が一覧にするキー)・予算の秒を値として持たず、参照で持つ。組んだプランのファイルを読むテストで確かめる +- [ ] 1. `supervise.py new sprint`(版を明示しない)・`new close`・`sprint-state.py loop sprint` が組むプランのファイルは、版数と記録の PR の番号を値として持たず、参照で持つ。設定から読んだ値と予算の秒は、今と同じく組んだ時点の値で持つ。組んだプランのファイルを読むテストで確かめる - [ ] 2. 参照は、ステップの直前(持ち主の記録を取った後)に実行の核が解決し、参照ごとの値と出所が実行の記録に残る。解決でプランのファイルは書き換わらない(流す前と後でファイルの sha256 が同じ) -- [ ] 3. 参照を解決できないとき(記録の PR が見つからない・origin の版かタグを読めない・宣言のキーが無い・前のプランの完了した結果が無い)は、ステップを流さず `stopped` で返り、結果 JSON に参照の名前と解決できなかった理由が出る。推定の値・空・`0`・`unknown` で埋めて流さない -- [ ] 4. 承認ゲート 2 の後に、承認したコミットの後へ許したもの以外のコミットが入った、または承認資料の版と解決した版が違うとき、本番のステップを流さず `gate` で返り、結果 JSON に承認時の値と実行時の値が並ぶ。承認したコミットの後に許したものだけが入ったときは流れる(前提 5) +- [ ] 3. 参照を解決できないとき(記録の PR が見つからない・origin の版かタグを読めない・前のプランの完了した結果が無い)は、ステップを流さず `stopped` で返り、結果 JSON に参照の名前と解決できなかった理由が出る。推定の値・空・`0`・`unknown` で埋めて流さない +- [ ] 4. 承認ゲート 2 の後に、承認したコミットの後へ許したもの以外のコミットが入った、または承認資料の版と解決した版が違うとき、本番のステップを流さず `gate` で返り、結果 JSON に承認時の値と実行時の値が並ぶ。承認したコミットの後に許したものだけが入ったときは流れる(前提 4) - [ ] 5. 同じプランの同じステップを流し直すと、前の実行で解決した版を使う。ただし #2164 の版の決め直し(自分の配布のマージより前に別のスプリントがその版を出した)に当たるときは、#2164 の規則で決め直し、決め直した前の版と理由が解決の記録に残る - [ ] 6. 実行条件を満たして飛ばすはずのプランを `--from <プラン>=<ステップ>` で始めても、そのプランは飛ばされ、結果に飛ばした旨が出る(#2349 の再現の手順で確かめる) - [ ] 7. 完了したプランを含む done のファイルを `--resume` しても、完了したプランは流れない。完了の後にプランのファイルが変わっていたら、流さずに `gate` で返り、変わったことと流し直す手段を結果に出す(#2349 の再現の手順で確かめる) - [ ] 8. `sprint-state.py loop sprint` は `--dev` を省いて起動でき、省いたとき組むスプリントのプランは版を参照で持つ。`--dev` を渡したときは今と同じく明示の版のプランになる(#2354 の再現の手順で確かめる) - [ ] 9. #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件のテストが、参照の部品へ寄せた後も通る。前提 3 の差し込みを解決する関数が、キュー(`supervise_lib/queue_fill.py`・`supervise_lib/queue.py`)に残らない -- [ ] 10. 前提 6 の旧い形のプラン(値を書き込んだプラン)を流すと、書き込まれた値で流れ、実行の記録に出所「プランの値」が残る -- [ ] 11. 宣言の値のうち秘密と宣言された入力(#2335 の I9)は、解決の記録に値が書かれず、出所の名前だけが残る -- [ ] 12. 子の課題 4 件(下の「子の課題」の表)それぞれについて、再現の手順を 1 回通した結果(コマンドと出力の要点)と、閉じた(解消を確かめた)か残した(理由つき)かを、この課題の本文の表に記録する。閉じた課題には、確かめた結果へのリンクを添える -- [ ] 13. 参照を持たない入口とプランの振る舞いが変わらない。全体テストが柱のマージの前後で通る +- [ ] 10. 前提 5 の旧い形のプラン(値を書き込んだプラン)を流すと、書き込まれた値で流れ、実行の記録に出所「プランの値」が残る +- [ ] 11. 子の課題 4 件(下の「子の課題」の表)それぞれについて、再現の手順を 1 回通した結果(コマンドと出力の要点)と、閉じた(解消を確かめた)か残した(理由つき)かを、この課題の本文の表に記録する。閉じた課題には、確かめた結果へのリンクを添える +- [ ] 12. 参照を持たない入口とプランの振る舞いが変わらない。全体テストが柱のマージの前後で通る ## 子の課題 -受け入れ条件 12 で、柱の仕組みを入れた後に 1 件ずつ再現の手順を通す。「見込み」は要求の整理(2026-11-04)での見立てで、結論ではない。 +受け入れ条件 11 で、柱の仕組みを入れた後に 1 件ずつ再現の手順を通す。「見込み」は要求の整理(2026-11-04)での見立てで、結論ではない。 | 課題 | 何が起きるか(要約) | 当たる受け入れ条件 | 見込み | | --- | --- | --- | --- | @@ -157,8 +160,8 @@ | 性能・拡張性 | 参照を持たない入口の起動に足す時間は、#2335 の上限 150 ms(核全体)の内側に収まる。GitHub と origin を読む参照は、その参照を持つステップだけが読む(今と同じ) | | 可用性 | 解決できないときと食い違うときは、待ち続けずに `stopped` か `gate` で返る(受け入れ条件 3・4) | | 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを、実行の記録の行に実行の識別子つきで書く(受け入れ条件 2) | -| 移行性 | 移す対象: 前提 3 の差し込みと、プランを組む入口が書き込む値。並走: 旧い形のプランは値のまま流す(受け入れ条件 10)。版の記録(`version.json`)は解決の記録として読む。切り戻し: 参照の段の Pull Request と入口を移す Pull Request を、それぞれ revert すれば前の形へ戻る | -| セキュリティ | 受け入れ条件 11。認証・認可(C2)と秘密の扱い(C1)は変えない | +| 移行性 | 移す対象: 前提 3 の差し込みと、プランを組む入口が書き込む版と記録の PR の番号。並走: 旧い形のプランは値のまま流す(受け入れ条件 10)。版の記録(`version.json`)は解決の記録として読む。切り戻し: 参照の段の Pull Request と入口を移す Pull Request を、それぞれ revert すれば前の形へ戻る | +| セキュリティ | 参照が解決する値は版・PR の番号・コミットの SHA・状態ディレクトリのパスで、秘密を含まない。認証・認可(C2)と秘密の扱い(C1)は変えない | | システム環境 | 版の比較は今と同じ `versions`(optional extras)を使う。extras が無い環境では、核が `stopped`(終了コード 69)と足りない extras の名前を返す(#2335 の受け入れ条件 15) | ## 影響 @@ -177,7 +180,7 @@ | 静的解析・構造の検査 | `python3 scripts/check-script-structure.py` | | 配布物の検証 | `claude plugin validate .` | | 手動確認(マージ前) | #2349 の再現の手順(実行条件を持つプランを `--from` で始める・完了したプランを含む done を `--resume` する)を、直した版のスクリプトで 1 回ずつ通し、結果を Pull Request に書く | -| 手動確認 | 子の課題 4 件の再現の手順を、本番へ出した版で通し、受け入れ条件 12 の表を埋める(リリース後テスト) | +| 手動確認 | 子の課題 4 件の再現の手順を、本番へ出した版で通し、受け入れ条件 11 の表を埋める(リリース後テスト) | ## 前提とする取り決め @@ -199,7 +202,6 @@ | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | -| 参照へ移す宣言のキーの一覧(`.ndf/pace.json`・`.ndf/release.json` などのうち、ステップの振る舞いを決めるもの) | この柱の設計 | 設計 Pull Request | | 参照の書き方(今の `{queue_record}` のような文字列の差し込みを続けるか、プランの型の欄にするか) | この柱の設計(柱 P6 のプランの型に従う) | 設計 Pull Request | | 流し直しの判定(受け入れ条件 7)で、プランの中身の同一性を何で測るか(ファイルの sha256・型で正規化した中身) | この柱の設計 | 設計 Pull Request | | 版の記録(`version.json`)を実行の記録へ移すか、状態ディレクトリに残して解決の記録から指すか | この柱の設計 | 設計 Pull Request | From ea65943b52cf537cf64a958346604d587e487b56 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:56:25 +0000 Subject: [PATCH 28/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=81=E7=89=88=E3=81=AE=E6=B1=BA=E3=82=81=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=AE=E9=80=80=E9=81=BF=E3=81=A8=E8=80=90=E4=B9=85?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E3=81=AE?= =?UTF-8?q?=E4=B8=96=E4=BB=A3=E3=82=92=E7=AF=80=E3=81=AB=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 処理の流れの「流れの決まり」から、退避の手順と耐久ワークフローの世代の決まりを別の節へ分けた (119 行の章のうち、この部分は 26 行の 1 節だった)。節を指す参照を新しい節の名前へそろえた - 100 字を超える文 3 つと、決定の記録の長い文を分けた。内容は足していない - 「参照の段」を、参照の仕組みの Pull Request・参照の解決へ言い換えた Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design-decisions.md | 14 +++--- issues/issue-2344-design.md | 62 ++++++++++++++++----------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/issues/issue-2344-design-decisions.md b/issues/issue-2344-design-decisions.md index 0df80987c..b0ec3ad0d 100644 --- a/issues/issue-2344-design-decisions.md +++ b/issues/issue-2344-design-decisions.md @@ -51,8 +51,8 @@ そのため設定の値と予算は今と同じく、プランを組むエントリポイントが組んだ時点の値で書く。設定を変えたときにプランへ反映するには、今と同じくプランを組み直す。 設定(導入の確認のコマンド・配布のテンプレート・本番のブランチなど 11 キー)と予算を参照の仕組みに載せる形は採らない。 -組んだ時点の設定の値をプランへ控え、流す前に今の値と照らして違えば止める形も採らない。どちらも起きた不具合に根拠が無く、 -設定の契約・プランの型・プランの実行の順序に足す欄と判定が、この柱の承認資料の量を増やすためである(利用者の範囲の指示、2026-11-05)。 +組んだ時点の設定の値をプランへ控え、流す前に今の値と照らして違えば止める形も採らない。どちらも起きた不具合に根拠が無いためである。 +また、設定の契約・プランの型・プランの実行の順序に欄と判定を足すことになり、この柱の承認資料の量が増える(利用者の範囲の指示、2026-11-05)。 根拠: Value 2 / Value 3 / Value 4(MVV 版 5) @@ -60,7 +60,7 @@ `--version` で渡した版は、利用者か conductor がその版で流すと決めたものである。参照にして流す時点の版へ置き換えると、明示した決定を黙って上書きする。 一方で明示の版にも確かめることがある。#2164 でも明示の版は決め直さず、既出なら止める。承認ゲート 2 の後は承認した版とも照らす(決定 6)。 -そこで `--version` の値は版の参照の宣言の `params.explicit` に入れ、参照の部品が値を置き換えずに既出の判定と承認した版との照合を通す。解決の記録の出所は `plan:explicit` である。 +そこで `--version` の値は版の参照の宣言の `params.explicit` に入れる。参照の部品は値を置き換えずに、既出の判定と承認した版との照合を通す。解決の記録の出所は `plan:explicit` である。 明示の版を参照にせずリテラルで持つ形は採らない。既出の判定と承認した版との照合を通る経路が無くなるためである。 @@ -69,9 +69,9 @@ ### 決定 6: 承認ゲート 2 で人が見た版と流す版を照らせるよう、承認資料に「承認した版」の行を足し、本番のエントリポイントはエントリポイントの契約の `refs` で参照の部品を通る 今の承認資料は承認したコミットの行だけを持つ。版は、承認ゲート 2 の通過の後に系列の版の記録が変わったか(`series_changed`)でしか照らせない。 -承認資料に「承認した版」を書けば、人が見た版と解決した版をそのまま比べられる。照合は承認したコミットと同じく `approved: gate-2` を付けた参照で行い、 +承認資料に「承認した版」を書けば、人が見た版と解決した版をそのまま比べられる。照合は承認したコミットと同じく `approved: gate-2` を付けた参照で行う。 食い違えば `gate` で人へ戻す。今の `series_changed` は `stopped` である。承認した値との食い違いは人が判断することなので `gate` にする(受け入れ条件 4)。 -本番のエントリポイントはプランからも手動でも呼ばれる。そのため照合をエントリポイントの契約の `refs` に置き、核がエントリポイントごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 +本番のエントリポイントはプランからも手動でも呼ばれる。そのため照合をエントリポイントの契約の `refs` に置く。核はエントリポイントごとに同じ参照の部品で照らす。照らす関数は `lib/approved_commit.py` の 1 か所にする。 照合をプランのステップの側だけに置く形は採らない。手動で本番のエントリポイントを呼んだときに照合が抜けるためである。 「承認した版」の行が無い旧い承認資料は、`series_changed` の照らし方で読む(次の正式版まで)。 @@ -81,7 +81,7 @@ ### 決定 7: 完了したプランの流し直しを中身の変更だけで判定できるよう、プランの同一性はプランの型で読んだ中身を正規化した JSON の sha256 で測る 決定 2 でプランのファイルは解決で変わらなくなる。ただし `jq` などで整形し直すと、キーの並びと空白だけが変わる。 -ファイルのバイト列の sha256 では、中身の同じプランを「変わった」と判定して人へ戻すことになる。型で読んだ値をキーを並べ替えた JSON にしてから取れば、意味の変わった編集だけを拾える。 +ファイルのバイト列の sha256 では、中身の同じプランを「変わった」と判定して人へ戻すことになる。型で読んだ値をキーを並べ替えた JSON にしてから取る。そうすれば、意味の変わった編集だけを拾える。 ハッシュは実行の記録の `start` 行とキューの done の項目に持ち、再開とキューの `--resume` が今のハッシュと照らす。 前のハッシュが無い旧い done では、完了したプランを流さない(受け入れ条件 7 の前半)。 @@ -89,7 +89,7 @@ 根拠: Value 3 / Value 8(MVV 版 5) -### 決定 8: 飛ばすはずのプランが開始のステップで流れないよう、実行条件は参照の段の最初に開始のステップによらず 1 回だけ評価する +### 決定 8: 飛ばすはずのプランが開始のステップで流れないよう、実行条件は参照の解決より先に、開始のステップによらず 1 回だけ評価する 実行条件は「このプランを流すか」の判定で、どのステップから始めるかと関係しない。今は開始のステップを渡すと評価を飛ばす(`engine.py:219`)。 そのため #2349 で取り込み済みのプランが流れた。参照より先に評価すれば、飛ぶプランは版を照会しない。キューが条件を先に打つ手当て(`_release_skipped_by_condition`)も要らなくなる。 diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index 51e9eca20..e801cc3b3 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -169,7 +169,7 @@ plugins/ndf/ └── supervise_lib/ ├── engine.py / queue.py / queue_resume.py # 変更 ├── queue_fill.py # 削除 - └── release_templates.py ほかプランを組む 6 ファイル # 変更 + └── release_templates.py など supervise_lib のプランを組む 6 ファイル # 変更 ``` ## 構造 @@ -206,8 +206,8 @@ classDiagram RefSpec ..> StoreRow : sticky なら読み書き ``` -- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決し、値を入れたプランのコピーをメモリ上で返す。実行条件の評価の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「流れの決まり」) -- **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決し、値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` +- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決する。値を入れたプランのコピーをメモリ上で返す。実行条件の評価の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「耐久ワークフローの世代」) +- **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決する。値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` - **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る - **種類のモジュールの形**: `resolve(ctx, spec, plan) -> Resolution | RefFailure`。GitHub と origin を読む種類は、その種類を使うステップでだけ読み込まれる @@ -361,29 +361,39 @@ sequenceDiagram ### 流れの決まり -- **実行条件を評価する関数は 1 つにする。** 呼ぶのは E4 と、版の決め直しの事前判定(下の「決め直しは、耐久ワークフローの回復より先に判定し」の項目の 2)だけである。今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない -- **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる(`release_lib/approval.py` から `lib/approved_commit.py` へ移す) -- **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路(同じディレクトリのプランの `report.md` と `work/`)を唯一の経路にする。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く +- **実行条件を評価する関数は 1 つにする。** 呼ぶのは E4 と、版の決め直しの事前判定(下の「耐久ワークフローの世代」の 2)だけである。今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない +- **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる。関数は `release_lib/approval.py` から `lib/approved_commit.py` へ移す +- **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路を、唯一の経路にする。その経路は、同じディレクトリのプランの `report.md` と `work/` を読む。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う -- **版の決め直しを確かめるのは、1 回の起動で耐久ワークフローの接頭辞を決める前の判定(下の「決め直しは、耐久ワークフローの回復より先に判定し」の項目)だけである。** その後の解決とステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない -- **決め直したら、旧版の成果物を退避して新版の先頭から流す。** 次の順で行い、プランのファイルは書き換えない - 1. 状態ディレクトリのうち、解決の控え(`refs.json`)・退避先(`superseded/`)・状態の実体のコピー(`plan.json`)を除くファイル(ステップの記録・結果・`work/`)を `superseded/v<旧版>/` へ移す - 2. 解決の控えの `rows.version` を新版で上書きし、`history` に `{from, to, reason, existing, observed, left, at}` を追記する。`left` は旧版の `作業場所`・`branch`・退避先である。旧版の worktree とブランチは消さずに残す(消すのは戻せない操作のため) - 3. `ref` 行を `outcome: "redecided"` で書く - 4. 上位の欄を新版で解決し直し、新しい作業場所と `branch` で worktree を作り、最初のステップから流す。旧版の済んだステップの記録は 1 で退避したため、再利用されない - - 1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。 -- **決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の 1 の退避では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める - 1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらも状態ディレクトリの控えから世代を読んで `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない - 2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動の判定は根拠にせず、その起動の実行条件で確かめる - 1. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない - 2. 上の判定(`taken` → `own_released`)を流す。当たれば上の 1〜3 を行ってから接頭辞を決める - - 飛ばすプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 が同じ結果(完了)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである - 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューは接頭辞を耐久ステップ `admit_step` の中で決め、キューの再開(`launch_queue` の `keep=None`)ではその出力が再生されて旧世代の接頭辞が使われる。そのため、2 の事前判定で 1 つでも決め直したプランがあれば、`launch_queue` を `fresh` で開く。途中の耐久ワークフローは子ごと止まり、`admit_step` が世代の増えた接頭辞を計算し直す。完了したプランは、完了の記録が接頭辞で引けるため流し直されない。決め直しが無ければ、これまでどおり続きから再開する - 4. 耐久ワークフローの中の `resolve_head` は、決め直しを起こさない。控えの版をそのまま使う(控えが無いときの最初の決定を除く)。決め直しの経路は 2 の判定の 1 つだけになる - - 2 の判定の後・1〜3 の退避の途中で落ちても、次の起動で 2 の判定が同じ結果を返すか(控えを書く前)、世代の増えた控えから新しい接頭辞で始まる(控えを書いた後)。どちらでも旧版の耐久ステップの記録は再利用されない。開始のステップ(`--from`)を渡した起動で決め直しが要るときは、旧版の済んだステップを前提にできないため流さず、`stopped`・終了コード 6、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す +- **版の決め直しを確かめるのは、1 回の起動で耐久ワークフローの接頭辞を決める前の判定だけである。** 判定の中身は下の「耐久ワークフローの世代」の 2 にある。その後の解決とステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない + +### 版を決め直したときの退避 + +**決め直したら、旧版の成果物を退避して新版の先頭から流す。** 次の順で行い、プランのファイルは書き換えない。 + +1. 状態ディレクトリのファイルのうち、ステップの記録・結果・`work/` を `superseded/v<旧版>/` へ移す。移さないのは、解決の控え(`refs.json`)・退避先(`superseded/`)・状態の実体のコピー(`plan.json`)である +2. 解決の控えの `rows.version` を新版で上書きする。`history` には `{from, to, reason, existing, observed, left, at}` を追記する。`left` は旧版の `作業場所`・`branch`・退避先である。旧版の worktree とブランチは消さずに残す(消すのは戻せない操作のため) +3. `ref` 行を `outcome: "redecided"` で書く +4. 上位の欄を新版で解決し直し、新しい作業場所と `branch` で worktree を作り、最初のステップから流す。旧版の済んだステップの記録は 1 で退避したため、再利用されない + +1 の後で落ちても、済んだステップの記録が無いため、次の起動は最初のステップから流れる。控えを書く前に落ちれば控えの判定で同じ決め直しへ戻り、書いた後に落ちれば新版で流れる。 + +### 耐久ワークフローの世代 + +**決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の「版を決め直したときの退避」の 1 では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める。 + +1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらも状態ディレクトリの控えから世代を読む。読んだ世代は `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない +2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動の判定は根拠にせず、その起動の実行条件で確かめる + 1. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない + 2. 「流れの決まり」の版の参照の判定(`taken` → `own_released`)を流す。当たれば「版を決め直したときの退避」の 1〜3 を行ってから接頭辞を決める + + 飛ばすプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 が同じ結果(完了)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである +3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューは接頭辞を耐久ステップ `admit_step` の中で決める。キューの再開(`launch_queue` の `keep=None`)では、その出力が再生されて旧世代の接頭辞が使われる。そのため、2 の事前判定で 1 つでも決め直したプランがあれば、`launch_queue` を `fresh` で開く。途中の耐久ワークフローは子ごと止まり、`admit_step` が世代の増えた接頭辞を計算し直す。完了したプランは、完了の記録が接頭辞で引けるため流し直されない。決め直しが無ければ、これまでどおり続きから再開する +4. 耐久ワークフローの中の `resolve_head` は、決め直しを起こさない。控えの版をそのまま使う(控えが無いときの最初の決定を除く)。決め直しの経路は 2 の判定の 1 つだけになる + +2 の判定の後・退避の 1〜3 の途中で落ちたときの次の起動は 2 通りになる。控えを書く前なら、2 の判定が同じ結果を返す。控えを書いた後なら、世代の増えた控えから新しい接頭辞で始まる。どちらでも旧版の耐久ステップの記録は再利用されない。 + +開始のステップ(`--from`)を渡した起動で決め直しが要るときは流さない。旧版の済んだステップを前提にできないためである。結果は `stopped`・終了コード 6 で、`gaps` に `ref:version`、`next` に `--from` を外したコマンドを出す。 ### 流し直しの判定(E9) @@ -409,7 +419,7 @@ stateDiagram-v2 | 性能・拡張性 | 参照を持たないエントリポイントの起動に足す時間は #2335 の上限 150 ms の内側。GitHub と origin はその参照を持つステップだけが読む | 核はエントリポイントの契約に `refs` が無ければ参照の部品を読み込まない。種類のモジュールは使う種類だけを読み込む | 参照を持たないエントリポイントの `--help` を前後で 10 回ずつ測り、差の中央値を実装の Pull Request に書く。GitHub を呼ぶ関数を差し替え、参照を持たないプランで呼ばれないことをテストで見る | | 可用性 | 解決できない・食い違うときは待ち続けずに返る | 種類のモジュールは待ちを持たず、外部の呼び出しは上限の部品(柱 P4)の 1 回の上限で打ち切る | 解決できない・食い違うテストが `stopped` / `gate` で返る | | 運用・保守性 | 解決した・解決できなかった・照合した・食い違ったを実行の識別子つきで書く | `ref` 行の `outcome` に 5 つの値 | 種類ごとのテストで `ref` 行の `outcome` を見る | -| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は参照の段(参照の部品・種類・プランの実行・キュー)とエントリポイントの移し(プランを組むエントリポイント・開発ループ)に分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | +| 移行性 | 旧い形のプランは値のまま流す。`version.json` は解決の記録として読む。2 つの Pull Request をそれぞれ revert すれば戻る | 移行関数(プランと控え)。Pull Request は、参照の仕組み(参照の部品・種類・プランの実行・キュー)と、エントリポイントの移し(プランを組むエントリポイント・開発ループ)の 2 つに分ける | 前の版が組んだプランと `version.json` の実物で流すテスト | | セキュリティ | 参照が解決する値は秘密を含まない。C1・C2 を変えない | 種類は `version`・`record_pr`・`plan_result`・`tip` の 4 つで、版・PR の番号・状態ディレクトリのパス・コミットの SHA だけを返す。設定と秘密を読む種類を持たない | 種類の表(データ構造の節)を設計レビューで見る | | システム環境 | 版の比較は `versions`(extras)。無い環境では `stopped`・69 | `version` の種類だけが `versions` を読み込み、読めなければ `deps` の判定で返す | extras を外した環境で版の参照を持つプランを流すテスト | From ee673450f46a422a475f069f6030c8f9055c6410 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 00:58:50 +0000 Subject: [PATCH 29/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=82=8B=E6=8C=87=E7=A4=BA=E3=82=92=E4=BA=BA?= =?UTF-8?q?=E3=81=8C=E9=80=81=E3=81=A3=E3=81=9F=E5=81=9C=E6=AD=A2=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=81=8B=E3=82=89=E7=BD=AE=E3=81=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-05 の利用者の判断(直して出し直す)に合わせ、要求の写しを作り直し、設計を置き換えた。 - 会話の記録から、lfactory2 の止める指示は conductor が別のセッションの「終了して」を読み替えて置いたものと確かめた - conductor の手順(loop.md)で止める指示の発生元を人が送った入力に限り、終える依頼では置かない - loop start で止める指示を消す処理と、経過時間・周回一覧・時刻の補正の変更を外し、コードは変えない Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 284 +++++++++++------------------- issues/issue-2474-requirements.md | 116 ++++++------ 2 files changed, 153 insertions(+), 247 deletions(-) diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md index 0bd57790b..8a6b2a823 100644 --- a/issues/issue-2474-design.md +++ b/issues/issue-2474-design.md @@ -1,24 +1,25 @@ -# 開発ループ: 周回の途中に置いた止める指示を再開で解き、止めていた時間を予算の経過時間から外す(#2474) +# 開発ループ: 止める指示を人が送った停止だけから置く(#2474) ## 目的 -- **今どうなっているか**: 周回の途中で `loop stop` を打ってセッションを切り、後から `loop start` で再開すると、止める指示のファイルが残る。周回を締めた境でそのファイルを読み、利用者が止めたという条件(`user_stop`)で止まり直す。止めていた時間は周回の区間に入ったまま、止まりの記録のどの行にも重ならないため、ループの予算の経過時間に数えられる(lfactory2 の周回 4 で約 41 時間、予算 72 時間の約 6 割) -- **誰に効くか**: 開発ループを使う利用者。周回の途中でループを休ませてセッションを切る使い方をする人 -- **変えると何が成り立つか**: `loop start` の再開で止める指示が解ける。休ませた区間は止まりの記録に 1 行残り、経過時間から外れる。再開の後に余分な止まりが挟まらず、予算の残りが休ませる前と同じに保たれる +- **今どうなっているか**: conductor の手順は、止める指示(`loop stop` が置くファイル)を誰の何を受けて置くかを決めていない。lfactory2 では、conductor が別のセッションからの「引継ぎ文書を書いて終了して」というメッセージを「ループを止めること」と読み替えて `loop stop` を打った。再開した後の周回の境で、誰も求めていない利用者の停止(`user_stop`)で止まり、本当に止まるべき理由(プロジェクト MVV の改訂)が出なかった +- **誰に効くか**: 開発ループを回す利用者。複数のループを並べ、別のセッションから conductor へ依頼を送る使い方をする人 +- **変えると何が成り立つか**: 止める指示は、人がそのセッションへ送った停止からだけ置かれる。セッションを終える依頼・別のセッションのメッセージ・画面の文字・サジェストからは置かれない。誤って置いた止める指示は、置いた conductor が消す ## 適用範囲 -- **働く範囲**: NDF の `sprint-state.py loop` の副命令(`start`・`lap`・`select`)。開発ループを使う配布先のリポジトリでも同じように働く -- **プロジェクトごとに違うもの**: 無し(足す設定・引数は無い。止める指示のファイルの置き場と中身は今のまま) -- **当たるモード**: 開発ループで回すすべてのモード(`pace: auto`) +- **働く範囲**: NDF の `development-workflow` の開発ループの手順(conductor が読む `references/loop.md`)。開発ループを使う配布先のリポジトリでも同じように働く +- **プロジェクトごとに違うもの**: 無し(足す設定・引数は無い) +- **当たるモード**: 開発ループで回すすべてのモード ## あるべき姿の根拠 | 根拠 | 種類 | 何を示すか | | --- | --- | --- | -| `.ndf/loop/lfactory2/loop.json` の記録(要求の「目的」の表): 周回 4 の開始 2026-11-02 13:04:20、止める指示 13:08:53、締めた時刻 2026-11-04 06:01:43、消費の hours 6.19 → 47.14、`user_stop` の行は 06:01:43〜06:02:11 | 実測 | 止めていた区間が止まりの記録に無く、経過時間から引かれていない | -| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の I8(止まっていた時間は経過時間に入らない)と再開の表(`user_stop` はファイルを消して解ける) | 既存の規則 | 止める指示は再開で解けること、止まっていた区間を経過時間から外すことが、すでに決まった形である | -| `plugins/ndf/scripts/lib/sprint_loop.py` の `cmd_start`(止まっていない記録では `in_lap` / `resumed` を返すだけで、止める指示のファイルを見ない) | 実測(コードの読み) | 止める指示を消すのは、止まった記録の再開(`_resume_user_stop`)の経路だけである | +| 会話の記録 `37e6fcce`(lflow)・`f3bae19d`(lfactory2)の 2026-11-02 13:07〜13:09 UTC(要求の「目的」の表) | 実測 | 止める指示を置いたのは conductor 自身の読み替えで、どの段にも人がループを止めることを送った記録が無い | +| 2026-11-05 の利用者の判断(課題 #2474 の「人の判断待ち」の節) | 利用者の決定 | 送信されていない書きかけの指示は指示として扱わず、止める指示は人が実際に送ったものだけを有効にする | +| `git grep -n "STOP_FILE" -- plugins/ndf/scripts/lib`(要求の前提 1) | 実測(コードの検索) | 止める指示のファイルを書くのは `loop stop`(`cmd_stop`)だけで、発生元を絞る場所は `loop stop` を打つ側にある | +| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の I3・I8 | 既存の規則 | 人が送った停止は次の境で効き、境で止まった後の区間はすでに経過時間から外れる | 要求と受け入れ条件は #2474 の本文にある(コピーは `issues/issue-2474-requirements.md`)。この文書は「どう作るか」だけを扱う。 @@ -27,10 +28,8 @@ | 呼び方 | 意味 | | --- | --- | | 止める指示 | `loop stop` が置くファイル `.ndf/loop/<名>/stop`。中身は置いた時刻 | -| 止まりの記録 | ループの記録(`loop.json`)の `stops`。解けた止まりの一覧(追記だけ) | -| 経過時間 | 確定仕様の I8 の「動いていた時間」。終わった周回の区間の和から、止まりの記録と重なる部分を引いたもの(結果の `consumed.hours`) | -| 止まっていない記録 | ループの記録の `status` が `running` のもの(`stopped` でないもの) | -| 休ませた区間 | 止める指示を置いた時刻 T1 から、`loop start` で再開した時刻 T2 まで | +| 人が送った入力 | 人がそのセッションの入力欄から送信した入力と、人が端末で打ったコマンド | +| 人が送っていない文 | 別のセッションからのメッセージ(「利用者の指示」と書かれていても含む)・引継ぎ文書・画面に出た文字・送信されていないサジェスト・conductor 自身の読み替え | ## ドメインモデル @@ -38,42 +37,37 @@ | コンテキスト | 何の語が 1 つの意味に決まるか | | --- | --- | -| NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ・周回・ループの予算・止まる条件・ループの記録・止める指示 | +| NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ・周回・止まる条件・ループの記録・止める指示 | 1 つのコンテキストに収まる。ほかのコンテキストとの関係は変えない。 ### 集約 -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 開発ループ | `sprint_loop.py` の副命令(記録の読み書きは `sprint_loop_record.py` の関数を通す) | ループの記録(`loop.json`) | 周回(`laps[]`) | 止まり(`stop` / `stops[]` の 1 行)・予算・消費 | +| 集約 | 持ち主(書き換えてよいもの) | 根 | この変更での扱い | +| --- | --- | --- | --- | +| 開発ループ | `sprint_loop.py` の副命令 | ループの記録(`loop.json`) | 変えない | -止める指示のファイルは集約の外にある入力である。置くのは利用者の `loop stop`、消すのは `loop start` だけにする。 -この変更で書き換えるのは開発ループの集約だけで、1 回の `loop start` が書き換える集約も 1 つである。 +止める指示のファイルは集約の外にある入力である。この変更は、その入力を置く側(conductor の手順)の決まりだけを変える。 +書き換える集約は無い。 ### 不変条件 | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | -| I1 | 開発ループ | 止まっていない記録の再開と、`user_stop` で止まった記録の再開では、`loop start` が終了コード 0 を返した後に止める指示のファイルが無い | テストが落ちる | -| I2 | 開発ループ | 止まっていない記録で進行中の周回があるときに止める指示を解いたら、止まりの記録に `condition: user_stop`・その周回の番号・`at` = T1・`resumed_at` = T2 の行がちょうど 1 行足される。進行中の周回が無いときは行を足さない | テストが落ちる | -| I3 | 開発ループ | 休ませた区間(T1〜T2)は、その周回の経過時間に入らない。周回の分は (T1 − T0) + (T3 − T2) になる | テストが落ちる | -| I4 | 開発ループ | T1 は T2 を超えない。止める指示の中身が時刻として読めないときは、ファイルの更新時刻を T1 にする | テストが落ちる | -| I5 | 開発ループ | 止める指示を消せなかったら、`loop start` は断り、ループの記録を書き換えない | テストが落ちる | -| I6 | 開発ループ | 止める指示のファイルが無いときの `loop start` の結果とループの記録は、今と同じである | 既存のテストが落ちる | +| I1 | 開発ループ(の入力) | 止める指示は、人が送った入力がループを止めることを求めたときにだけ置かれる | 手順書の決まりで守る。機械では検査しない(決定 1)。破れたら置いた conductor がファイルを消す(I2) | +| I2 | 開発ループ(の入力) | conductor が消してよい止める指示は、自分が人の送っていない文から置いたものだけである | 手順書の決まりで守る | +| I3 | 開発ループ | `loop start`・周回の境の判定・経過時間の計算の振る舞いは今と同じである | 既存のテストが落ちる | ### ドメインイベント | # | イベント | 発生元 | 受け手 | | --- | --- | --- | --- | -| E2 | 止める指示を置いた | 利用者の `loop stop` | `loop start`(E5)・周回の境の判定(E8) | -| E4 | 再開を求めた | 利用者の `loop start` | `cmd_start` | -| E5 | 止める指示を解いた | `cmd_start`(止まっていない記録で止める指示がある) | 結果の `released_stop`・周回の境の判定(`user_stop` に当たらなくなる) | -| E6 | 休ませた区間を記録した | `cmd_start`(E5 で進行中の周回がある) | 経過時間の計算(`active_seconds`)・`laps.md` の描画 | -| E8 | 境で止まる条件を判定した | `loop lap` の後・`loop select` の前(`boundary_stop`) | 止まる条件の判定。E5 が先に起きていれば `user_stop` は当たらない | +| E1 | 人がループを止めることを送った | 人の入力・人が端末で打つ `loop stop` | conductor | +| E2 | 止める指示を置いた | E1 を受けた conductor の `loop stop` | 周回の境の判定(`user_stop`) | +| E3 | セッションを終える依頼を受けた | 人の入力・別のセッションのメッセージ | conductor。引継ぎ文書を書いて終える。E2 を起こさない | +| E4 | 人が送っていない文から置いた止める指示に気づいた | 止める指示を置いた conductor | 止める指示のファイル(消す)・引継ぎ文書(消したことを書く) | -要求の E1(周回を始めた)・E3(セッションが終わった)・E7(周回を締めた)は振る舞いを変えないため、この表に載せない。 -E3 は記録が残らないため受け手を持たない(要求の前提 1)。 +要求の E5(境で止まる条件を判定した)は振る舞いを変えないため、この表に載せない。 ### 用語 @@ -81,224 +75,142 @@ E3 は記録が残らないため受け手を持たない(要求の前提 1) | --- | --- | --- | | 開発ループ | スプリントを 1 つ終えるたびに次のスプリントを選んで続けて始める進め方 | 無し(既存の語をそのまま使う) | | 周回 | 開発ループの 1 回分 | 無し | -| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える | 無し | | 止まる条件 | 開発ループを止める、機械で判定できる条件 | 無し | | ループの記録 | `.ndf/loop/<名>/loop.json` | 無し | -新しい語は足さない。「止める指示」「休ませた区間」はこの文書の中の呼び方で、用語集へは足さない。 +新しい語は足さない。「止める指示」「人が送った入力」「人が送っていない文」はこの文書の中の呼び方で、用語集へは足さない。 ## 機能一覧 | # | 機能 | 誰が使うか | | --- | --- | --- | -| F1 | 周回の途中でループを休ませた後、`loop start` で再開すると止める指示が解ける | 開発ループを回す利用者と conductor | -| F2 | 周回の外(進行中の周回が無い)で置いた止める指示も、`loop start` で解ける | 同上 | -| F3 | 休ませた区間が止まりの記録と `laps.md` に残り、ループの予算の経過時間から外れる | 同上 | +| F1 | conductor は、人が送った入力がループを止めることを求めたときだけ `loop stop` を打つ | conductor | +| F2 | セッションを終える依頼では、`loop stop` を打たずに引継ぎ文書を書いて終える | conductor | +| F3 | 人が送っていない文から置いた止める指示に気づいたら、ファイルを消して無視し、引継ぎ文書に書く | conductor | ## 構成要素 | 要素 | 責務 | 変える / 変えない | | --- | --- | --- | -| `cmd_start`(`plugins/ndf/scripts/lib/sprint_loop.py`) | 止まっていない記録の経路で、止める指示があれば解く関数を呼び、結果に `released_stop` を載せる | 変える | -| `release_stop_request`(新設。`sprint_loop.py`) | 止める指示の時刻 T1 を読み、ファイルを消し、進行中の周回があれば止まりの記録へ 1 行足す。消せなければ断る結果を返す | 作る | -| `stop_request_time`(新設。`plugins/ndf/scripts/lib/sprint_loop_record.py`) | 止める指示のファイルから T1 を読む(中身 → 読めなければ更新時刻)。T2 を超えれば T2 に揃える | 作る | -| `lap_rows`(`sprint_loop_record.py`) | `laps.md` の周回の行の「止まった理由」に、その周回の止まりを起きた順にすべて並べる | 変える | -| `active_seconds`(`sprint_loop_record.py`) | 止まりの記録の `at`〜`resumed_at` と周回の区間の重なりを引く | 変えない(足した行をそのまま読む) | -| `boundary_stop`(`sprint_loop_record.py`) | 周回の境で止まる条件を判定する(順も変えない) | 変えない | -| `resume_stopped` / `_resume_user_stop`(`sprint_loop.py`) | 止まった記録の再開 | 変えない(要求の前提 5)。承認ゲート 2 の差し戻し(`gate2_returned`)と周回を組めなかった止まり(`refused`)の再開は、止める指示のファイルを消さない(「未確認のまま残ること」) | -| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` | 再開の表・I3・I8・`start` の契約・ループの記録の置き場の表を振る舞いに合わせる | 変える | - -```mermaid -graph TD - U[利用者の
loop start] --> S[cmd_start] - S -->|止まった記録| R[resume_stopped
今のまま] - S -->|止まっていない記録| L[release_stop_request] - L --> T[stop_request_time] - L -->|周回の途中なら
1 行足す| J[(ループの記録の
止まりの記録)] - L -->|消す| F[(止める指示の
ファイル)] - J --> A[active_seconds
経過時間] - J --> M[lap_rows
laps.md] -``` - -図に含めない要素: 周回の境の判定(`boundary_stop`。変えず、止める指示が無いことを読むだけ)と確定仕様(文書)。 +| `plugins/ndf/skills/development-workflow/references/loop.md` の「止まる条件」の節 | 止める指示を置く発生元と、置かない場面と、誤って置いたときの扱いを conductor へ示す | 変える(節の中に小見出し「止める指示を置く」を足す) | +| 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` | 止める指示の発生元を確定の振る舞いとして残す | 変える | +| `cmd_stop` / `cmd_start` / `boundary_stop` / `active_seconds`(`plugins/ndf/scripts/lib/sprint_loop*.py`) | 止める指示の読み書き・再開・境の判定・経過時間 | 変えない(決定 2・3) | +| `references/parallel.md` の「レーンの画面を見るとき」 | 画面の入力欄の文字を判断に使わない | 変えない。`loop.md` の新しい小見出しからリンクする | ### 値の集合へ値を足す影響 -止まりの記録(`stops`)へ、周回の途中で解いた `user_stop` の行が加わる。行の形(`condition`・`values`・`lap`・`at`・`resumed_at`)は -既存の行と同じである。止まりの記録を読む関数は `grep -rn "stops" plugins/ndf/scripts --include=*.py` で集めた(2026-11-04)。 -テストを除いた当たりを 1 つずつ判定した結果が次の表である。`merged_lib/merge.py` と `test_strategy.py` の当たりは -別のオブジェクトの `stops` 属性で、ループの記録を読まない。 - -| 読む関数 | 新しい行への当てはまり | 扱い | -| --- | --- | --- | -| `active_seconds` | 当てはまる。`at`〜`resumed_at` と周回の区間の重なりを引くのが、この変更の狙いそのものである | 変えない | -| `failure_streak` | 当てはまる。`same_failure` の行だけを区切りに使い、`user_stop` の行は読み飛ばす | 変えない | -| `lap_rows` | 当てはまらない。周回ごとに止まりを 1 つだけ(後の行で上書き)持つため、休ませた後に同じ周回が別の理由で止まると `user_stop` が消える | 変える(決定 4) | -| `resume_record` | 書く側。止まった記録の再開で行を足す | 変えない | +値の集合へ値を足さない(止まる条件・結果の `status`・ループの記録の形は変えない)。 ## 入出力の契約 -`sprint-state.py loop start` の結果に 1 つ値を足す。既存の値は変えない。 - -| 場合 | 終了コード | `metrics.status` | 足す値 | -| --- | --- | --- | --- | -| 止まっていない記録・進行中の周回あり・止める指示あり | 0 | `in_lap` | `released_stop: {at, source, lap}`。`summary` に「止める指示を解いた」を足す | -| 止まっていない記録・進行中の周回なし・止める指示あり | 0 | `resumed` | `released_stop: {at, source, lap: null}`。同上 | -| 止める指示が無い | 今のまま | 今のまま | 足さない(キー自体を出さない) | -| 止める指示を消せない | 1 | `refused` | `items` に `{path, reason}`。ループの記録は書かない | -| 止まった記録(`stopped`) | 今のまま | 今のまま | 足さない(要求の前提 5) | - -`released_stop` の値: +`sprint-state.py loop` の副命令の引数と結果は変えない。 -| キー | 型 | 意味 | -| --- | --- | --- | -| `at` | 文字列(ISO 8601、UTC) | 休ませた区間の始まり T1 | -| `source` | `content` / `mtime` | T1 を止める指示の中身から読んだか、更新時刻から読んだか | -| `lap` | 整数 / `null` | 止まりの記録へ行を足した周回の番号。進行中の周回が無ければ `null` | - -止まりの記録へ足す行(形は既存の行と同じ): +`loop.md` へ足す小見出しの中身(実装でこの内容を書く。文面は実装で整える): -```json -{"condition": "user_stop", "values": {"at": "<止める指示の中身のまま>"}, "lap": 4, "at": "", "resumed_at": ""} -``` - -`values.at` は周回の境の判定(`boundary_stop`)が `user_stop` に書く値と同じく、ファイルの中身をそのまま残す。 -中身が読めなかった場合も、空・壊れた中身を `values.at` に残し、`at` に更新時刻を入れる。 +| 項目 | 書く内容 | +| --- | --- | +| 置く場面 | 人が自分のセッションへ送った入力が、ループを止めることを求めたときだけ `$SS loop stop <名>` を打つ | +| 発生元にしないもの | 別のセッションからのメッセージ(「利用者の指示」と書かれていても含む)・引継ぎ文書・画面に出た文字(`parallel.md` の「レーンの画面を見るとき」へリンク)・送信されていないサジェスト・自分の読み替え。これらが止めることを求めていたら、人に確かめるまで置かない | +| 置かない場面 | セッションを終える依頼(引き継いで終了する・新しい版で起動し直す)。引継ぎ文書を書いて終える。周回は進行中のまま残り、次の起動の `loop start` で続く | +| 誤って置いたとき | 人が送っていない文から置いたと気づいたら、`.ndf/loop/<名>/stop` を消し、消したことと理由を引継ぎ文書に書く。人が置いた止める指示と、置いた経緯が分からない止める指示は消さない | +| 理由(1 文) | 止める指示は再開の後も残り、次の境で `user_stop` として効く。誰も求めていない停止で止まると、本当に止まるべき理由が出ない | ## 処理の流れ +conductor が止めることを求める文を受けたときの判断を、次の流れにする。 + ```mermaid -sequenceDiagram - participant U as 利用者 - participant S as cmd_start - participant F as 止める指示 - participant R as ループの記録 - U->>S: loop start 名 - S->>R: 読む(排他の中) - alt status が stopped - S->>S: resume_stopped(今のまま) - else 止まっていない記録 - S->>F: ある? - alt 無い - S-->>U: in_lap / resumed(今のまま) - else ある - S->>F: T1 を読む(中身 → 更新時刻) - S->>F: 消す - alt 消せない - S-->>U: 終了コード 1(記録を書かない) - else 消せた - opt 進行中の周回がある - S->>R: 止まりの記録へ 1 行(T1〜T2) - end - S->>R: 書く - S-->>U: in_lap / resumed と released_stop - end - end - end +flowchart TD + A[止める・終えることを
求める文を受けた] --> B{人が自分のセッションへ
送った入力か} + B -->|いいえ| C[止める指示を置かない
人に確かめる] + B -->|はい| D{ループを止めることを
求めているか} + D -->|はい| E[loop stop を打つ] + D -->|セッションを
終えるだけ| F[引継ぎ文書を書いて終える
loop stop を打たない] ``` -`cmd_start` の中の順は、予算の上書き(`apply_budget`)→ 止める指示を解く → 記録を書く、にする。 -止める指示を消すのは記録を書く前である(決定 3)。続く `loop lap` の後の境の判定は今のまま `boundary_stop` を通り、 -止める指示が無いため `user_stop` に当たらず、`mvv_changed` などの次の条件を見る。 - -## 決定の記録 +人が送っていない文から止める指示をすでに置いていたと気づいたときは、ファイルを消して引継ぎ文書に書く(F3)。 -### 決定 1: 再開を求めた印を 1 か所で受けるため、止まっていない記録の止める指示を `loop start` で解く +図は `loop.md` の新しい小見出しが conductor へ示す判断である。図に含めない要素: 確定仕様(文書)、変えない開発ループのコード、 +リンク先の `parallel.md`。 -止まった記録の `user_stop` は、すでに `loop start` で解ける。止まっていない記録で止める指示を残すと、同じ利用者の操作 -(`loop stop` → `loop start`)が、周回の境の前か後かで違う結果になる。`loop start` を打つこと自体を再開の印として扱い、 -どちらの経路でも止める指示を消す。 +## 決定の記録 -この決定が揃えるのは、止まっていない記録の再開と `user_stop` で止まった記録の再開の 2 つである。周回の途中で -`gate2_returned`・`refused` で止まった記録の再開は、要求の前提 5 のとおり変えないため、止める指示が残る(「未確認のまま残ること」)。 +### 決定 1: 発生元の限定は conductor の手順で決め、コマンドや hook の側で入力元を判定しない -周回の境の判定(`boundary_stop`)の側で「再開より前に置かれた止める指示を読み飛ばす」形は採らない。再開の時刻を別に -持つ必要があり、止める指示のファイルも残り続けるため、`loop status` と利用者の目に止める指示が残って見える。 +止める指示を置くのは `loop stop` だけで、`loop stop` を打つのは conductor か人である。人が送った入力かを機械で +見分けるには、その入力の出所を知る値が要る。hook が受ける入力の項目に入力元は無い。Claude Code の会話の記録には入力元の値 +(`promptSource`・`turnOrigin`)があるが公開の仕様ではなく、Codex には無い。lfactory2 の起点の入力も記録上は `typed` で、 +サジェストを採った入力と区別できなかった(要求の「目的」の表)。 -根拠: Value 7 / Vision(MVV 版 5) +hook で `loop stop` の呼び出しを会話の記録の入力元で止める形は採らない。内部の値に依存し、ランタイムによって働かない。 -### 決定 2: 休ませた区間を経過時間から外すため、止まりの記録へ既存と同じ形の行を足す +根拠: Value 6 / Value 5(MVV 版 5) -経過時間の計算(`active_seconds`)は、止まりの記録の `at`〜`resumed_at` と周回の区間の重なりをすでに引く。休ませた区間を -同じ形の 1 行として足せば、計算の側を変えずに I8 が成り立つ。`laps.md` の止まりの欄にも同じ経路で出る。 +### 決定 2: `loop start` で止める指示を消さない -周回に「休ませた秒」の欄を新しく足す形は採らない。経過時間の計算と `laps.md` の両方に読み手を足すことになり、止まりの -記録と同じ意味の値を 2 か所に持つ。 +人が送った停止は、次の周回の境で効くのが正しい振る舞いである(確定仕様の I3)。自動の再起動(カットポイントと利用上限での +切り替え)も周回の途中で `loop start` を打つため、`loop start` で止める指示を消すと、人が送った停止が境の前に消える。 +発生元を人が送った停止に限れば、再開の後に止める指示が効くのは人の意図どおりである。 -根拠: Value 7 / Value 8(MVV 版 5) +根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(MVV 版 5) -### 決定 3: 消せなかったときに記録だけが進まないよう、止める指示を消してから記録を書く +### 決定 3: 経過時間の計算を変えない -止める指示を先に消し、消せなければ記録を書かずに終了コード 1 で断る(要求の E5)。記録を先に書いてから消す順では、 -消せなかったときに止まりの記録の行だけが残り、次の境で同じ止める指示が `user_stop` として効き、同じ区間が 2 回扱われる。 +人が送った停止では、conductor は境まで周回を続けるため、止める指示を置いてから境までは動いていた時間である。境で止まった +後の区間は、確定仕様の I8 ですでに経過時間から外れる。外すべき区間は止める指示からは生じない。 -消した後に記録の書き込みが失敗した場合は、例外がそのまま上がる(今の `write_record` の失敗と同じ扱い)。このとき -休ませた区間は経過時間から外れないが、止める指示は解けており、再開の後に余分な止まりは挟まらない。 +lfactory2 の周回 4 に入った約 41 時間は、周回の途中でセッションを終えて 2 日後に再開した区間である。セッションが止まった +時刻を記録する仕組みが無いため、この変更では扱わない(「未確認のまま残ること」)。 -根拠: Value 1(MVV 版 5) +根拠: Value 3 / Value 4(MVV 版 5) -### 決定 4: 休ませた事実が後の止まりで消えないよう、`laps.md` の止まりの欄に周回の止まりをすべて並べる +### 決定 4: セッションを終える依頼を止める指示に読み替えない -今の `lap_rows` は周回ごとに止まりを 1 つだけ持ち、後の行で上書きする。休ませた周回が締めた後の境で `mvv_changed` などで -止まると、`user_stop` が欄から消える。止まりの記録の順(起きた順)に、条件を `、` で区切って並べる。止まりが 1 つの周回の -欄は今と同じ文字列になる。 +セッションを終える依頼の目的は、周回を進行中のまま残し、次の起動で続けることである。止める指示を置くと、次の起動で続けた +周回が境で `user_stop` で止まり、依頼の目的と逆になる。lfactory2 で起きたのはこの読み替えである。 -`laps.md` へ休ませた区間だけの別の表を足す形は採らない。周回の一覧の 1 つの欄で足りる。 +根拠: Mission / Value 2(MVV 版 5) -根拠: Value 8(MVV 版 5) +### 決定 5: 誤って置いた止める指示は、置いた conductor だけが消す -### 決定 5: 時刻の誤りで経過時間を負に数えないよう、T1 を T2 以下に揃える +止める指示のファイルの中身は置いた時刻だけで、誰が何を受けて置いたかを持たない。後から読んだ conductor には、人が置いた +ものと見分けられない。消してよいのは、自分が人の送っていない文から置いたと分かっている conductor だけにする。経緯が +分からない止める指示は消さず、境で `user_stop` として止まり、人が `loop start` で解く(今の再開の表のとおり)。 -止める指示の中身が未来の時刻(手で書いた・時計のずれ)だと、`at` が `resumed_at` より後の行になる。重なりの計算は 0 を -返すが、記録として読めない行が残る。T1 が T2 を超えたら T2 にそろえる(区間の長さは 0)。T1 が周回の開始 T0 より前の -とき(周回を組む前に置いた止める指示)は揃えない。重なりの計算が周回の区間で切るため、引きすぎない。 +止める指示のファイルに置いた理由を書き足す形は採らない。書く内容を検証できず、決定 1 と同じく人が送ったかを判定できない。 -根拠: Value 1(MVV 版 5) +根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(MVV 版 5) ## テスト設計 -置き場は `plugins/ndf/scripts/tests/test_sprint_loop.py`。時刻は `Env.now` の差し替えで進める。 - | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | -| 受け入れ条件 1・I1 | 進行中の周回があり止める指示があるとき、`loop start` が 0・`in_lap`・ファイルなし・`released_stop` ありを返す | 止まっていない記録の経路で止める指示を見ないように戻すと落ちる | -| 受け入れ条件 2 | 1 の後に `loop lap`(`--failed` あり・なし)を締めると、境の判定が `user_stop` に当たらない。MVV を改訂しておけば `mvv_changed` で止まる | ファイルを消さずに `released_stop` だけを返すと落ちる | -| 受け入れ条件 3・I2 の後半 | 進行中の周回が無いとき、`loop start` が 0・`resumed`・ファイルなしを返し、止まりの記録に行が増えず、続く `loop select` が `user_stop` で止まらない | 周回が無いときにも行を足すと落ちる | -| 受け入れ条件 4・I3 | T0 < T1 < T2 < T3 の順に時刻を進めると、`consumed.hours` の周回の分が (T1 − T0) + (T3 − T2) になる | 行の `at` に T2 を書く・`resumed_at` を書かないと落ちる | -| 受け入れ条件 5・I2 の前半 | 止まりの記録に `user_stop`・周回の番号・`at` = T1・`resumed_at` = T2 の行がちょうど 1 行あり、`laps.md` の周回の行の止まりの欄に `user_stop` が出る | 行を 2 回足す・周回の番号を書かないと落ちる | -| 決定 4 | 休ませた周回がその後 `mvv_changed` で止まっても、`laps.md` の欄に `user_stop` と `mvv_changed` の両方が出る | 後の止まりで上書きする今の形に戻すと落ちる | -| 受け入れ条件 6・I4 | 止める指示の中身が空・時刻でないとき、ファイルの更新時刻が T1 になり、`source` が `mtime` になる。中身が T2 より後の時刻なら `at` が T2 になる | 中身が読めないときに T2 を使う・揃えずに未来の時刻を残すと落ちる | -| I5 | 止める指示を消せない(消す関数を失敗させる)とき、終了コード 1 で、ループの記録の中身が呼ぶ前と同じ | 記録を先に書いてから消す順にすると落ちる | -| 受け入れ条件 7・I6 | 止める指示が無いとき、`loop start` の結果に `released_stop` が無く、`in_lap` / `resumed` とループの記録が今と同じ | ファイルの有無を見ずに行を足すと落ちる | -| 受け入れ条件 8 | 既存の `test_resume_stopped_*` と `test_stop_file_works_at_the_next_boundary_and_resume_removes_it` が通る | 止まった記録の再開の経路を変えると落ちる | -| 受け入れ条件 9 | 確定仕様の再開の表・I3・I8・`start` の契約が振る舞いを書いている。文言を照合するテストは書かず、コードレビューで読む | — | +| 受け入れ条件 1〜4・I1・I2 | `loop.md` の手順。文言を照合するテストは書かず、設計と実装の Pull Request のレビューで読む | — | +| 受け入れ条件 5 | 確定仕様の記述。レビューで読む | — | +| 受け入れ条件 6・I3 | 既存の `plugins/ndf/scripts/tests/test_sprint_loop.py` が通る | 開発ループのコードを変えると落ちる | +| 手順書の参照 | `loop.md` から `parallel.md` の見出しへのリンクを、リンクの検査(`python3 scripts/check-markdown-links.py --root .`。継続的統合の `runtime-plugin-validate.yml` と同じ)で確かめる | 見出しの名前を違えると落ちる | ## 確定仕様の直し方 -`docs/specifications/ndf-workflow-development-loop.md` を次のとおりに直す(受け入れ条件 9)。 +`docs/specifications/ndf-workflow-development-loop.md` を次のとおりに直す(受け入れ条件 5)。 | 箇所 | 直す内容 | | --- | --- | -| 常に成り立つ条件の I3 | 止める指示は周回の境でだけ効く、を残し、「止まっていない記録の `loop start` は止める指示を消し、進行中の周回があれば休ませた区間(止める指示の時刻〜再開)を `stops` へ `user_stop` の行として足す」を加える | -| 常に成り立つ条件の I8 | 引く区間に、周回の途中で解いた止める指示の行(I3)が含まれることを書く | -| 副命令の表の `start` | 出力に `released_stop`(`{at, source, lap}`。止める指示を解いたときだけ)を足す。止める指示を消せないときの 1 を足す | -| 再開の表 | 「止まっていない記録に止める指示がある」の行を足す(ファイルを消し、周回の途中なら区間を `stops` へ足す。`in_lap` / `resumed` のまま) | -| ループの記録の置き場の表 | `stop` の行を「`loop stop` が置き、`loop start` で消す」にする | -| `stop` / `stops` の形の段落 | 周回の途中で解いた行も同じ形であることを書く | -| テストの表 | 受け入れ条件 1〜6 の縛りを 1 行足す | +| 冒頭の「利用者の止める指示では…止まる」の行 | 止める指示は人が送った停止からだけ置かれることを添える | +| 常に成り立つ条件の I3 | 止める指示を置くのは、人が送った入力がループを止めることを求めたときだけで、セッションを終える依頼・別のセッションのメッセージからは置かないことを加える | +| ループの記録の置き場の表の `stop` の行 | 「人が送った停止を受けて `loop stop` が置き、`user_stop` の再開で消す」にする | ## 設計の結果 | 課題 | 扱い | 取り込み先 | 触るファイル | | --- | --- | --- | --- | -| #2474 | 実装する | — | `plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_record.py`、`plugins/ndf/scripts/tests/test_sprint_loop.py`、`docs/specifications/ndf-workflow-development-loop.md` | +| #2474 | 実装する | — | `plugins/ndf/skills/development-workflow/references/loop.md`、`docs/specifications/ndf-workflow-development-loop.md` | ## 未確認のまま残ること | 項目 | 内容 | | --- | --- | -| 止める指示を置いた後もセッションが周回を進めていた時間 | 要求の前提 1 のとおり、その時間も経過時間から外れる。セッションが止まった時刻を記録する仕組みが無いため、実際に動いていた時間との差は測れない。承認ゲート 1 で利用者が前提 1 を確かめる | -| 消した後の記録の書き込みの失敗 | 決定 3 のとおり、休ませた区間が経過時間に残る。発生の頻度は未確認(今の `write_record` の失敗と同じ扱い) | -| `loop stop` と `loop start` の取り合い | `loop stop` は排他を取らない。`loop start` が止める指示を読んで消す間に別の `loop stop` が書くと、後の指示も消える。同時に打つ使い方は確認していない | -| 周回の途中で止まった記録の再開に残る止める指示 | 周回の途中で `loop stop` を置いた後に、承認ゲート 2 の差し戻し(`gate2_returned`)か周回を組めなかった止まり(`refused`)で止まると、その記録の再開(`resume_stopped`)は止める指示のファイルを消さない。再開の後の周回の境で `user_stop` が出直し、止める指示を置いた時刻 T1 から止まった時刻までが経過時間に残る。#2474 と同じ症状がこの経路に残る。直すには止まった記録の再開を変える必要があり、要求の前提 5 と受け入れ条件 8 に触れるため、この設計では直さない。直すかは課題 #2474 の本文で決める | -| リリース後の実機確認 | 次に開発ループを回すとき、周回の途中で `loop stop` → セッションを切る → `loop start` を通し、`laps.md` の経過時間に休ませた時間が入らないことを見る(要求の検証手段) | +| lflow の起点の入力がサジェストだったか | 会話の記録の入力元の値は `typed` で、サジェストを採った入力の値(`suggestion_accepted`)ではない。利用者の判断(2026-11-05)ではサジェストを指示と読んだものである。どちらであっても、ループを止めることは誰も送っていない(要求の「目的」の表) | +| 手順書の決まりが守られるか | 決定 1 のとおり機械では検査しない。守られたかは、次に別のセッションから終える依頼を受けた conductor の振る舞いで見る(リリース後テスト) | +| 経緯が分からない止める指示 | 再開した conductor が、人の送っていない文から置かれた止める指示を見分ける手段は無い(決定 5)。lfactory2 と同じ場合は、境で `user_stop` で止まり、人が `loop start` で解く | +| 周回の途中でセッションを終えた区間 | セッションを終えてから再開までの区間は経過時間に入る(lfactory2 の約 41 時間)。セッションが止まった時刻を記録する仕組みが無く、この変更では扱わない | diff --git a/issues/issue-2474-requirements.md b/issues/issue-2474-requirements.md index 021fbe7af..3ae993601 100644 --- a/issues/issue-2474-requirements.md +++ b/issues/issue-2474-requirements.md @@ -1,4 +1,4 @@ -# #2474: 開発ループ: 周回の途中で止める指示を置くと、再開で解けず、止めていた時間が経過時間に入る +# #2474: 開発ループ: 止める指示を人が送った停止だけから置く(人が送っていない文から置かれた止める指示が、再開後の境で user_stop を起こした) 正は課題の本文(#2474)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 @@ -31,65 +31,62 @@ ## 起票者の推定 -題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。2026-11-05 の利用者の判断(下の「人の判断待ち」の節)で扱いを決めた。 | # | 条件 | 推した出所 | この要求での扱い | | --- | --- | --- | --- | -| S1 | 周回の途中に置いた止める指示は、`loop start` の再開で解ける(周回の境で止まったときと同じ) | 本文の「期待」の 1 行目。確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の再開の表(`user_stop` はファイルを消して解ける)からの類推 | 受け入れ条件 1・2・3 | -| S2 | 止める指示を置いてから再開までの時間は、ループの予算の経過時間に数えない | 本文の「期待」の 2 行目。確定仕様の不変条件 I8(止まっていた時間は経過時間に入らない) | 受け入れ条件 4・5、前提 1 | -| S3 | 直す場所は `cmd_start` の周回の途中の経路と、境の判定・周回の秒 | 本文の「範囲」 | 直す場所は設計が決める。受け入れ条件は場所を指定しない | -| S4 | 「周回の秒」に止めていた時間が入った | 本文の「何が起きたか」の 3 | 採らない。記録を見ると、周回 4 の秒(計画の行の秒の和)は 0 で、止めていた時間が入ったのはループの予算の経過時間(消費の hours)である(下の「目的」の実測) | +| S1 | lfactory2 の止める指示は「利用者の指示」で置かれた | 本文の「何が起きたか」の 1 行目。止める指示を置いた conductor が引継ぎ文書に「利用者の指示で `loop stop` を打った」と書いていた | 採らない。記録では、人はループを止めることを送っていない(下の「目的」の表) | +| S2 | 周回の途中に置いた止める指示は、`loop start` の再開で解ける | 本文の「期待」の 1 行目 | 採らない。人が送った停止は次の周回の境で効くのが正しい振る舞いである。自動の再起動も周回の途中で `loop start` を打つため、再開で消すと人が送った停止が境の前に消える(前提 4) | +| S3 | 止める指示を置いてから再開までの時間を、ループの予算の経過時間に数えない | 本文の「期待」の 2 行目 | 採らない。人が送った停止では conductor が境まで周回を続けるため、止める指示から境までは実際に動いていた時間である。境で止まった後の区間は、確定仕様の I8 ですでに経過時間から外れる(前提 3) | +| S4 | 直す場所は `cmd_start` の周回の途中の経路と、境の判定・周回の秒 | 本文の「範囲」 | 採らない。直すのは止める指示を置く経路(conductor の手順)である | +| S5 | 「周回の秒」に止めていた時間が入った | 本文の「何が起きたか」の 3 | 採らない。周回 4 の秒(計画の行の秒の和)は 0 で、時間が入ったのはループの予算の経過時間(消費の hours)である | ## 目的 -周回の途中で開発ループを止めて後から再開したとき、止める指示が再開で解け、止めていた時間がループの予算を使わない状態にする。利用者がセッションを切ってループを休ませても、再開した後に余分な止まりが 1 回挟まらず、予算の残りが休ませる前と同じに保たれる。 +止める指示(`.ndf/loop/<名>/stop`)が、人が実際に送った停止からだけ置かれる状態にする。人が送っていない文(送信されていないサジェスト・入力欄に残った文字・人の指示を名乗る別のセッションのメッセージ)から止める指示が置かれると、ループは誰も求めていない利用者の停止(`user_stop`)で止まり、本当に止まるべき理由が出ない。 -実際に起きた値(`.ndf/loop/lfactory2/loop.json` の記録、時刻は UTC): +記録で確かめた経路(2026-11-02、時刻は UTC。会話の記録 `37e6fcce`・`f3bae19d` とループの記録 `.ndf/loop/lfactory2/loop.json`): -| 項目 | 値 | -| --- | --- | -| 周回 4 の開始 | 2026-11-02 13:04:20 | -| 止める指示のファイルの中身(置いた時刻) | 2026-11-02 13:08:53 | -| 周回 4 を締めた時刻(`user_stop` の止まりの時刻) | 2026-11-04 06:01:43 | -| 周回 4 の秒(計画の行の秒の和) | 0 | -| 消費の hours(周回 4 の前 → 後) | 6.19 → 47.14 | +| 時刻 | 誰が | 何を読んで | 何をした | +| --- | --- | --- | --- | +| 13:07:22 | ループ lflow の conductor のセッション | 人の入力として記録された「3本に引継ぎメモを作って終了する指示を送ってください。」。記録の入力元の値は `typed` で、サジェストを採った入力の値(`suggestion_accepted`)ではない。利用者は 2026-11-05 に、この入力をサジェストを指示と読んだものと判断した | 3 本のループの conductor へ、別のセッションへのメッセージで「【利用者の指示・lflow 経由】引継ぎ文書を最新にして、このセッションを終了してください」を送った | +| 13:07:39 | ループ lfactory2 の conductor のセッション(`f3bae19d`) | 上のメッセージ(入力元は別のセッション) | パイプラインを止め、引継ぎ文書を書いた | +| 13:08:52 | 同じ | 同じメッセージ。ループを止めることは書かれていない | 「利用者の指示はループを止めること」と自分で読み替えて `loop stop lfactory2` を打ち、引継ぎ文書に「利用者の指示で打った」と書いた | +| 2026-11-04 06:01:43 | 再開した conductor | 残っていた止める指示 | 周回 4 を締めた境で `user_stop` で止まった。プロジェクト MVV の改訂(`mvv_changed`)は出なかった | -周回 4 の区間(開始から締めるまで、約 40.95 時間)がそのまま経過時間に入った。止める指示の時刻から締めた時刻までの約 40.88 時間は、止まりの記録(`stops`)のどの行にも重ならないため引かれなかった(`user_stop` の行は締めた時刻 06:01:43 に始まり 06:02:11 に解けている)。 +どの段にも、人がループを止めることを送った記録は無い。止める指示は、conductor が別のセッションのメッセージを自分で読み替えて置いた。周回 4 の経過時間に入った約 41 時間(消費の hours 6.19 → 47.14)は、周回の途中でセッションを終えて 2 日後に再開した区間であり、止める指示が作った区間ではない。 ## 前提 -- 前提 1: 止まっていた区間の始まりは、止める指示のファイルに書かれた時刻(`loop stop` を打った時刻)とする。止める指示を置いた後もセッションが周回を進めていた場合、その時間も経過時間から外れる。セッションがいつ止まったかを記録する仕組みが今は無く、止める指示の時刻が手に入る最も近い値であるため、この扱いを採る -- 前提 2: 止める指示のファイルの中身が時刻として読めない(空・壊れている)ときは、ファイルの更新時刻を区間の始まりとする -- 前提 3: 止まっていた区間の終わりは、`loop start` で再開した時刻とする -- 前提 4: 周回の途中でない(進行中の周回が無い)ときに `loop start` が止める指示を見つけた場合も、止める指示を解く。`loop start` を打つこと自体が、利用者が再開を求めた印であるため。この場合、止まっていた区間は周回の区間の外にあるため、経過時間は変わらない -- 前提 5: 既に止まっている記録(`status` が `stopped`)の再開は、今の再開の表のとおりに動く。この課題では変えない +- 前提 1: 止める指示のファイルを書くのは `sprint-state.py loop stop`(`cmd_stop`)だけである。`git grep -n "STOP_FILE" -- plugins/ndf/scripts/lib` の当たりは、定数の定義と読み込みを除くと、`cmd_stop` が書き、周回の境の判定(`boundary_stop`)が読み、止まった記録の再開(`_resume_user_stop`)が消す 3 か所である +- 前提 2: 入力を人が送ったかを、コマンドや hook の側で見分ける値は無い。hook が受ける入力の項目に入力元は無く、Claude Code の会話の記録にある入力元の値(`promptSource`・`turnOrigin`)は公開の仕様ではなく、Codex には無い +- 前提 3: 人が送った停止は、次の周回の境で効く(確定仕様の I3)。conductor は境まで周回を続けるため、止める指示が置かれてから境までは動いていた時間である。境で止まった後の区間は確定仕様の I8 で経過時間から外れる +- 前提 4: 自動の再起動(カットポイントと利用上限での切り替え)も、周回の途中で `loop start` を打つ ## 対象範囲 含む: -- `loop start` が、止まっていない記録(進行中の周回がある・無いの両方)で止める指示を見つけたときに、止める指示を解く振る舞い -- 周回の途中で止める指示を置いてから再開するまでの区間を、止まりの記録へ残し、ループの予算の経過時間から外すこと -- 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の、再開の表と不変条件 I3・I8 の記述を振る舞いに合わせること +- conductor の手順(`development-workflow` の `references/loop.md`)に、止める指示を置く発生元を人が送った入力に限ることを書く +- セッションを終える依頼(引き継いで終了する)では止める指示を置かないことを書く +- 人が送っていない文から止める指示を置いたと気づいたときに、ファイルを消して無視することを書く +- 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の、止める指示の発生元の記述 含まない: -- 既存のループの記録(lfactory2 など)の値を直すこと。lfactory2 は `mvv_changed` で止まっており再開しない -- 周回の秒(計画の行の秒の和)の数え方。止めていた時間は入っていない(起票者の推定 S4) -- 止める指示が周回の途中で効く(周回の途中で止まる)ようにすること。止める指示は今のとおり次の周回の境で効く -- 境の判定の順(`user_stop` を `mvv_changed` より先に見ること)を変えること。止める指示が再開で解ければ、次の境で `mvv_changed` が出る -- セッションが止まった時刻を記録する仕組み(前提 1) +- `loop start` と経過時間の計算を変えること(前提 3・4) +- 中継が tmux に起動する claude のサジェストを止めること(#2504) +- 画面の入力欄の文字を判断に使わない決まり(PR #2502 で develop に入った) +- 周回の途中でセッションを終えた区間(lfactory2 の約 41 時間)を経過時間から外すこと。セッションが止まった時刻を記録する仕組みが無い +- 既存のループの記録(lfactory2 など)の値を直すこと ## ドメインイベント | # | イベント | 引き金 | 失敗したとき | 順序の前提 | | --- | --- | --- | --- | --- | -| E1 | 周回を始めた | `loop sprint` | 周回を組めなければ `refused` で止まる(今のとおり) | — | -| E2 | 止める指示を置いた | 利用者の `loop stop` | ループの記録が無ければ断る(今のとおり) | — | -| E3 | セッションが終わった | 利用者がセッションを切る・端末が落ちる | 記録は残らない(前提 1) | E2 の後とは限らない | -| E4 | 再開を求めた | 利用者の `loop start` | 使ってよい条件に当たらなければ断る(今のとおり) | E2 | -| E5 | 止める指示を解いた | E4 で止める指示のファイルがある | ファイルを消せなければ再開を断り、記録を書き換えない | E4 | -| E6 | 止まっていた区間を記録した | E5 で進行中の周回がある | 区間の始まりが読めなければ前提 2 の時刻を使う | E5 | -| E7 | 周回を締めた | `loop lap` | 今のとおり | E1 | -| E8 | 境で止まる条件を判定した | E7 の後・選定の前 | 今のとおり | E5 が先に起きていれば `user_stop` は当たらない | +| E1 | 人がループを止めることを送った | conductor のセッションへの人の入力、または人が端末で打つ `loop stop` | — | — | +| E2 | 止める指示を置いた | E1 を受けた conductor の `loop stop` | ループの記録が無ければ断る(今のとおり) | E1 | +| E3 | セッションを終える依頼を受けた | 人の入力・別のセッションのメッセージ | — | 止める指示を置かない | +| E4 | 人が送っていない文から置いた止める指示に気づいた | 止める指示を置いた conductor | — | ファイルを消して無視する | +| E5 | 境で止まる条件を判定した | 周回を締めた後・選定の前 | 今のとおり | E2 があれば `user_stop` で止まる | ## 用語 @@ -97,57 +94,54 @@ | --- | --- | | 開発ループ | スプリントを 1 つ終えるたびに次のスプリントを選んで続けて始める進め方(用語集) | | 周回 | 開発ループの 1 回分(用語集) | -| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える(用語集)。この文書の「経過時間」は、確定仕様の I8 の「動いていた時間」(終わった周回の区間の和から、止まりの記録と重なる部分を引いたもの。結果の `consumed.hours`)を指す | +| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える(用語集) | | 止まる条件 | 開発ループを止める、機械で判定できる条件(用語集)。止める指示は `user_stop` に当たる | -| ループの記録 | `.ndf/loop/<名>/loop.json`(用語集)。止まりの記録は、その中の解けた止まりの一覧(`stops`)を指す | +| ループの記録 | `.ndf/loop/<名>/loop.json`(用語集) | -止める指示は、`loop stop` が置くファイル `.ndf/loop/<名>/stop`(中身は置いた時刻)を指す。 +止める指示は、`loop stop` が置くファイル `.ndf/loop/<名>/stop`(中身は置いた時刻)を指す。人が送った入力は、人がそのセッションの入力欄から送信した入力と、人が端末で打ったコマンドを指す。別のセッションからのメッセージ・引継ぎ文書・画面に出た文字・送信されていないサジェスト・conductor 自身の推測は含まない。 ## 受け入れ条件 -- [ ] 1. 前提: 進行中の周回があり、止める指示のファイルがある。操作: `loop start <名>`。結果: 終了コード 0、`status` が `in_lap`、止める指示のファイルが消えている、結果に止める指示を解いたことが出る -- [ ] 2. 前提: 1 の再開の後。操作: その周回を `loop lap <名>`(`--failed` の有無を問わない)で締める。結果: 境の判定で `user_stop` に当たらない。ほかの止まる条件(`mvv_changed` など)に当たればそれで止まり、当たらなければ次の周回へ進む -- [ ] 3. 前提: 進行中の周回が無く、止まっていない記録に止める指示のファイルがある。操作: `loop start <名>`。結果: 終了コード 0、`status` が `resumed`、止める指示のファイルが消えている。続く `loop select` が `user_stop` で止まらない -- [ ] 4. 前提: 周回を始めた時刻 T0、止める指示を置いた時刻 T1、再開した時刻 T2、周回を締めた時刻 T3(T0 < T1 < T2 < T3)。結果: 締めた後の経過時間(`consumed.hours`)の、この周回の分が (T1 − T0) + (T3 − T2) になる。T2 − T1 は入らない -- [ ] 5. 4 の止まっていた区間(T1〜T2)が、ループの記録の止まりの記録に、周回の番号・始まり T1・終わり T2 つきで 1 行残る。`laps.md` の周回の行の止まりの欄に `user_stop` が出る -- [ ] 6. 止める指示のファイルの中身が時刻として読めないとき、1 と 4 の振る舞いは、ファイルの更新時刻を T1 として成り立つ -- [ ] 7. 止める指示のファイルが無いときの `loop start` の結果(`in_lap` / `resumed`)と、ループの記録の中身は変わらない -- [ ] 8. 既に止まっている記録(`stopped`)の再開は、止まった理由ごとに今と同じ結果になる。既存のテスト(`plugins/ndf/scripts/tests/test_sprint_loop.py`)が通る -- [ ] 9. 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の再開の表と I3・I8 が、受け入れ条件 1〜5 の振る舞いを書いている +- [ ] 1. `references/loop.md` に、conductor が `loop stop` を打つのは、人が自分のセッションへ送った入力がループを止めることを求めたときだけ、と書かれている +- [ ] 2. 1 の発生元にしないもの(別のセッションからのメッセージ。「利用者の指示」と書かれていても含む。引継ぎ文書・画面に出た文字・送信されていないサジェスト・conductor 自身の読み替え)が書かれている +- [ ] 3. セッションを終える依頼(引き継いで終了する)では `loop stop` を打たず、引継ぎ文書を書いてセッションを終える、と書かれている +- [ ] 4. 人が送っていない文から止める指示を置いたと気づいた conductor は、そのファイルを消し、消したことを引継ぎ文書に書く、と書かれている。人が置いた止める指示は消さない +- [ ] 5. 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` に、止める指示の発生元が人が送った停止に限られることが書かれている +- [ ] 6. 開発ループのコード(`plugins/ndf/scripts/lib/sprint_loop*.py`)は変えない。既存のテスト(`plugins/ndf/scripts/tests/test_sprint_loop.py`)が通る ## 影響 | 対象 | 影響 | | --- | --- | -| 公開インタフェース | `sprint-state.py loop start` の結果に、止める指示を解いたことを示す値が加わる。既存の値は変わらない(互換あり) | -| データ | ループの記録の止まりの記録に、周回の途中で止める指示を解いた行(`user_stop`)が加わる。形は既存の行と同じ。移行は無い | -| 既存の振る舞い | 止まっていない記録の `loop start` が、止める指示のファイルを消すようになる。止まっていた区間が経過時間から外れ、ループの予算の `hours` が今より少なく数えられる | +| 公開インタフェース | 無し(`sprint-state.py loop` の副命令と結果は変えない) | +| データ | 無し | +| 既存の振る舞い | conductor が `loop stop` を打つ場面が、人が送った停止に限られる | ## 検証手段 | 項目 | 手段 | | --- | --- | -| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_sprint_loop.py -q`(受け入れ条件 1〜8。時刻は `Env.now` の差し替えで進める) | -| テスト(全体) | `uv run --frozen --project . --all-extras pytest . -q -n 4` | -| 静的解析 | `ruff format --check` と `ruff check`(継続的統合の `lint.yml` と同じ) | -| 手動確認 | 次に開発ループを回すとき、周回の途中で `loop stop` → セッションを切る → `loop start` を通し、止める指示が消え、`laps.md` の経過時間に休ませた時間が入らないことを見る(リリース後テスト) | +| 文書のレビュー | 受け入れ条件 1〜5 を設計と実装の Pull Request のレビューで読む(`.md` の文言を照合するテストは書かない) | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_sprint_loop.py -q`(受け入れ条件 6) | +| リンクの検査 | `python3 scripts/check-markdown-links.py --root .`(手順書から足したリンクの参照切れ) | +| 手動確認 | 次に別のセッションからセッションを終える依頼を受けた conductor が、`loop stop` を打たずに引継ぎ文書を書いて終えることを見る(リリース後テスト) | ## 前提とする取り決め | 項目 | 参照先 / 決めたこと | | --- | --- | -| プロジェクト構造 | 開発ループのコードは `plugins/ndf/scripts/lib/sprint_loop*.py`。外の世界は `Env` を通して呼び、テストで差し替える(`sprint_loop_record.py` の先頭の説明) | +| 手順書の置き場 | conductor の手順は `plugins/ndf/skills/development-workflow/references/loop.md` | | コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | -| テスト戦略 | 単体テストで `cmd_start`・`cmd_lap`・経過時間の計算を、差し替えた `Env` と時刻で確かめる(既存の `test_stop_file_works_at_the_next_boundary_and_resume_removes_it` と同じ階層) | +| 関連の決まり | 画面の入力欄の文字を判断に使わない(`references/parallel.md` の「レーンの画面を見るとき」) | ## 境界 | 区分 | 内容 | | --- | --- | | 常に行う | 既存テストの実行、確定仕様の該当箇所の更新 | -| 確認してから行う | `loop start` の結果の形の変更(承認ゲート 1・2 で見る) | -| 行わない | 既存のループの記録の書き換え、境の判定の順の変更、止める指示を周回の途中で効かせること | +| 確認してから行う | 無し | +| 行わない | 開発ループのコードの変更、既存のループの記録の書き換え | ## 未決 -無し(決められない点は前提 1〜5 に置いた。前提 1 の扱いは承認ゲート 1 で利用者が確かめる) +無し(人が送ったかを機械で見分けない理由は前提 2 に置いた) From eaf2e874e877a2a4ba0d3c268be0ebe927b476c4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:01:19 +0000 Subject: [PATCH 30/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AE?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E6=96=B9=E3=82=92=E4=B8=AD=E8=BA=AB=E3=81=AE?= =?UTF-8?q?=E7=AB=A0=E3=81=AE=E6=9C=AB=E5=B0=BE=E3=81=B8=E7=A7=BB=E3=81=97?= =?UTF-8?q?=E3=80=81=E6=A0=B9=E6=8B=A0=E3=81=AE=20Value=20=E3=81=AB?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E6=B7=BB=E3=81=88=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「確定仕様の直し方」を「処理の流れ」の直後へ移し、何をどう書くかの章を決定の記録の前にまとめた - 決定の根拠の Value の番号に題を添えた。「どの段にも」を「どちらの会話の記録にも」に置き換えた - 内容は足していない(行数 216 → 216) Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md index 8a6b2a823..5a9f7e45b 100644 --- a/issues/issue-2474-design.md +++ b/issues/issue-2474-design.md @@ -16,7 +16,7 @@ | 根拠 | 種類 | 何を示すか | | --- | --- | --- | -| 会話の記録 `37e6fcce`(lflow)・`f3bae19d`(lfactory2)の 2026-11-02 13:07〜13:09 UTC(要求の「目的」の表) | 実測 | 止める指示を置いたのは conductor 自身の読み替えで、どの段にも人がループを止めることを送った記録が無い | +| 会話の記録 `37e6fcce`(lflow)・`f3bae19d`(lfactory2)の 2026-11-02 13:07〜13:09 UTC(要求の「目的」の表) | 実測 | 止める指示を置いたのは conductor 自身の読み替えで、どちらの会話の記録にも人がループを止めることを送った記録が無い | | 2026-11-05 の利用者の判断(課題 #2474 の「人の判断待ち」の節) | 利用者の決定 | 送信されていない書きかけの指示は指示として扱わず、止める指示は人が実際に送ったものだけを有効にする | | `git grep -n "STOP_FILE" -- plugins/ndf/scripts/lib`(要求の前提 1) | 実測(コードの検索) | 止める指示のファイルを書くのは `loop stop`(`cmd_stop`)だけで、発生元を絞る場所は `loop stop` を打つ側にある | | 確定仕様 `docs/specifications/ndf-workflow-development-loop.md` の I3・I8 | 既存の規則 | 人が送った停止は次の境で効き、境で止まった後の区間はすでに経過時間から外れる | @@ -133,6 +133,16 @@ flowchart TD 図は `loop.md` の新しい小見出しが conductor へ示す判断である。図に含めない要素: 確定仕様(文書)、変えない開発ループのコード、 リンク先の `parallel.md`。 +## 確定仕様の直し方 + +`docs/specifications/ndf-workflow-development-loop.md` を次のとおりに直す(受け入れ条件 5)。 + +| 箇所 | 直す内容 | +| --- | --- | +| 冒頭の「利用者の止める指示では…止まる」の行 | 止める指示は人が送った停止からだけ置かれることを添える | +| 常に成り立つ条件の I3 | 止める指示を置くのは、人が送った入力がループを止めることを求めたときだけで、セッションを終える依頼・別のセッションのメッセージからは置かないことを加える | +| ループの記録の置き場の表の `stop` の行 | 「人が送った停止を受けて `loop stop` が置き、`user_stop` の再開で消す」にする | + ## 決定の記録 ### 決定 1: 発生元の限定は conductor の手順で決め、コマンドや hook の側で入力元を判定しない @@ -144,7 +154,7 @@ flowchart TD hook で `loop stop` の呼び出しを会話の記録の入力元で止める形は採らない。内部の値に依存し、ランタイムによって働かない。 -根拠: Value 6 / Value 5(MVV 版 5) +根拠: Value 6(どのプロジェクトでも使える形を保つ) / Value 5(決まった手順はスクリプトに、判断と中身は AI に)(MVV 版 5) ### 決定 2: `loop start` で止める指示を消さない @@ -152,7 +162,7 @@ hook で `loop stop` の呼び出しを会話の記録の入力元で止める 切り替え)も周回の途中で `loop start` を打つため、`loop start` で止める指示を消すと、人が送った停止が境の前に消える。 発生元を人が送った停止に限れば、再開の後に止める指示が効くのは人の意図どおりである。 -根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(MVV 版 5) +根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(根本原因の場所で直す)(MVV 版 5) ### 決定 3: 経過時間の計算を変えない @@ -162,14 +172,14 @@ hook で `loop stop` の呼び出しを会話の記録の入力元で止める lfactory2 の周回 4 に入った約 41 時間は、周回の途中でセッションを終えて 2 日後に再開した区間である。セッションが止まった 時刻を記録する仕組みが無いため、この変更では扱わない(「未確認のまま残ること」)。 -根拠: Value 3 / Value 4(MVV 版 5) +根拠: Value 3(やめる・縮めるを既定にする) / Value 4(外の利用で測って決める)(MVV 版 5) ### 決定 4: セッションを終える依頼を止める指示に読み替えない セッションを終える依頼の目的は、周回を進行中のまま残し、次の起動で続けることである。止める指示を置くと、次の起動で続けた 周回が境で `user_stop` で止まり、依頼の目的と逆になる。lfactory2 で起きたのはこの読み替えである。 -根拠: Mission / Value 2(MVV 版 5) +根拠: Mission / Value 2(人が判断できる量に収める)(MVV 版 5) ### 決定 5: 誤って置いた止める指示は、置いた conductor だけが消す @@ -179,7 +189,7 @@ lfactory2 の周回 4 に入った約 41 時間は、周回の途中でセッシ 止める指示のファイルに置いた理由を書き足す形は採らない。書く内容を検証できず、決定 1 と同じく人が送ったかを判定できない。 -根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(MVV 版 5) +根拠: 共通原則の上位の原則(人の判断を守る) / Value 7(根本原因の場所で直す)(MVV 版 5) ## テスト設計 @@ -190,16 +200,6 @@ lfactory2 の周回 4 に入った約 41 時間は、周回の途中でセッシ | 受け入れ条件 6・I3 | 既存の `plugins/ndf/scripts/tests/test_sprint_loop.py` が通る | 開発ループのコードを変えると落ちる | | 手順書の参照 | `loop.md` から `parallel.md` の見出しへのリンクを、リンクの検査(`python3 scripts/check-markdown-links.py --root .`。継続的統合の `runtime-plugin-validate.yml` と同じ)で確かめる | 見出しの名前を違えると落ちる | -## 確定仕様の直し方 - -`docs/specifications/ndf-workflow-development-loop.md` を次のとおりに直す(受け入れ条件 5)。 - -| 箇所 | 直す内容 | -| --- | --- | -| 冒頭の「利用者の止める指示では…止まる」の行 | 止める指示は人が送った停止からだけ置かれることを添える | -| 常に成り立つ条件の I3 | 止める指示を置くのは、人が送った入力がループを止めることを求めたときだけで、セッションを終える依頼・別のセッションのメッセージからは置かないことを加える | -| ループの記録の置き場の表の `stop` の行 | 「人が送った停止を受けて `loop stop` が置き、`user_stop` の再開で消す」にする | - ## 設計の結果 | 課題 | 扱い | 取り込み先 | 触るファイル | From e244568cf3d5013a2224262fd742ebe80aa1133b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:03:25 +0000 Subject: [PATCH 31/39] =?UTF-8?q?Docs:=20#2344=20=E3=81=AE=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=81=AE=20worktree=20=E3=81=AE=E4=BD=9C?= =?UTF-8?q?=E6=88=90=E3=83=BB=E6=8E=A5=E9=A0=AD=E8=BE=9E=E3=81=AE=E4=B8=AD?= =?UTF-8?q?=E8=BA=AB=E9=8D=B5=E3=83=BB=E6=9C=AC=E7=95=AA=E3=81=AE=E7=89=88?= =?UTF-8?q?=E3=81=AE=E7=85=A7=E5=90=88=E3=83=BB=E4=BA=8B=E5=89=8D=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=82=92=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=E3=81=AB=E6=9B=B8=E3=81=8D=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - キューがステージの前に worktree を作る耐久ステップは、上位の欄に参照を持つプランを作らない - 耐久ワークフローの接頭辞の中身鍵をプランのハッシュから取る - 本番のエントリポイントの契約に、承認した版と照らす版の参照を足す - 決め直しの事前判定の前に流し直しの判定を置き、流さない完了したプランを外す Co-Authored-By: Claude Opus 5.5 --- issues/issue-2344-design.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/issues/issue-2344-design.md b/issues/issue-2344-design.md index e801cc3b3..150cde6a6 100644 --- a/issues/issue-2344-design.md +++ b/issues/issue-2344-design.md @@ -116,12 +116,12 @@ | プランの型(柱 P6 の `lib/model/` のプラン) | `refs`(参照の宣言)の欄を持つ。旧い差し込みを参照へ読み替える移行関数を持つ | 変更(柱 P6 の型に欄を足す) | | 解決の控えの型(`lib/model/` の `refs-store`) | 控えの形と、`version.json` を控えへ読み替える移行関数 | 新設 | | プランの実行(`supervise_lib/engine.py`) | ステップの直前に実行条件 → 参照の解決の順に呼ぶ。開始のステップで実行条件を飛ばさない | 変更 | -| キュー(`supervise_lib/queue.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す) | +| キュー(`supervise_lib/queue.py`・`supervise_lib/flow.py`) | 差し込みを埋めない。done の項目にプランのハッシュを書き、参照で止まったプランに `unfilled: true` を付ける。ステージの前に worktree を作る耐久ステップ(`flow.worktrees_step`)は、上位の欄(`作業場所`・`branch`)に参照を持つプランの worktree を作らない。そのプランの worktree は、プランの実行が `resolve_head` で解決した値で作る(実行条件を持つプランと同じ扱い) | 変更(`fill_stage` の埋める処理と `_release_skipped_by_condition`・`fill_queue_prs` を消す。`worktrees_step` の対象の条件を変える) | | 差し込みの解決(`supervise_lib/queue_fill.py`) | — | 削除(`FINISHED`・`named`・`results_named` は `ref_kinds/plan_result.py` へ移す) | | 再開(`supervise_lib/queue_resume.py`・柱 P3 の `supervise.py resume`) | 完了したプランをプランのハッシュで照らし、違えば `gate` で返す | 変更 | | プランを組むエントリポイント(`supervise_lib/release_templates.py`・`delivery_templates.py`・`sprint_routes.py`・`sprint.py`・`sprint_waves.py`・`design_merge.py`、`lib/parallel_release.py`。差し込みを書く 7 ファイル) | 差し込みの代わりに参照を書く。明示した版は版の参照の `explicit` に書く。設定・予算・引数で明示した上限とブランチは今と同じく値で書く | 変更 | | 開発ループ(`lib/sprint_loop_cli.py`・`lib/sprint_loop.py`) | `--dev` を任意にし、省いたら `new sprint` に `--version` を、`sprint-state.py init` に `--dev` / `--prod` を渡さない | 変更 | -| エントリポイントの契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番のエントリポイントの行に `refs`(`tip` の参照)を足す | 変更 | +| エントリポイントの契約(`plugins/ndf/contracts/entrypoints/`) | 配布の本番のエントリポイントの行に `refs` を足す。`tip` の参照(承認したコミットと照らす)と、引数の `--version` を `params.explicit` に入れ `approved: gate-2` を付けた `version` の参照(承認した版と照らす)の 2 つである | 変更 | ### 要素の関係 @@ -167,7 +167,7 @@ plugins/ndf/ │ └── release_version.py # 変えない ├── release_lib/approval.py # 変更: check_approved を approved_commit へ移す └── supervise_lib/ - ├── engine.py / queue.py / queue_resume.py # 変更 + ├── engine.py / flow.py / queue.py / queue_resume.py # 変更 ├── queue_fill.py # 削除 └── release_templates.py など supervise_lib のプランを組む 6 ファイル # 変更 ``` @@ -206,7 +206,7 @@ classDiagram RefSpec ..> StoreRow : sticky なら読み書き ``` -- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決する。値を入れたプランのコピーをメモリ上で返す。実行条件の評価の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「耐久ワークフローの世代」) +- **`resolve_head(ctx, plan) -> (plan, list[Resolution]) | RefFailure`**: プランの上位の欄(`作業場所`・`branch`)が使う参照を解決する。値を入れたプランのコピーをメモリ上で返す。実行条件の評価の後、worktree を作る前(`ensure_worktree` の前)に 1 回だけ呼ぶ。worktree の準備とその後のステップは、このコピーを使う。キューがステージの前に worktree を作る `worktrees_step` は `resolve_head` を呼ばないため、上位の欄に `{ref:` を含むプランを対象から外し、生の値(`release/v{ref:version}` など)で worktree とブランチを作らない。控えの無い版の最初の決定はこの解決で行い、決め直しはこの解決では起こさない(決め直しは耐久ワークフローを始める前に判定する。下の「耐久ワークフローの世代」) - **`resolve(ctx, plan, step) -> (step, list[Resolution]) | RefFailure`**: ステップが使う参照だけを解決する。値を入れたステップのコピーを返す。`plan` は `resolve_head` が返したコピーである。`RefFailure.status` は `stopped` か `gate` - **`plan_digest(plan) -> str`**: プランのハッシュ。プランの型で読んだ値を `json.dumps(sort_keys=True, ensure_ascii=False, separators=(",", ":"))` にして sha256 を取る - **種類のモジュールの形**: `resolve(ctx, spec, plan) -> Resolution | RefFailure`。GitHub と origin を読む種類は、その種類を使うステップでだけ読み込まれる @@ -362,7 +362,7 @@ sequenceDiagram ### 流れの決まり - **実行条件を評価する関数は 1 つにする。** 呼ぶのは E4 と、版の決め直しの事前判定(下の「耐久ワークフローの世代」の 2)だけである。今の `engine.py` の `and not start` を外す。`queue._release_skipped_by_condition`(版を照会する前に条件を打つ手当て)を消す。実行条件が版の参照より先に評価されるため、配布不要のプランは版を照会しない -- **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らす。`check_approved` を呼ぶ箇所はこの 1 つになる。関数は `release_lib/approval.py` から `lib/approved_commit.py` へ移す +- **配布の本番のエントリポイントは、エントリポイントの契約の `refs` で同じ参照の部品を通る。** プランから呼ばれても手動で呼ばれても、核の E5 で `tip` の参照を解決して承認したコミットと照らし、引数の版を `params.explicit` に入れた `version` の参照を解決して承認資料の「承認した版」と照らす。手動で `release --version <版B> --approval <版A の承認資料>` と呼んだときも、コミットの照合とは別に版の照合で `gate` になる。`check_approved` を呼ぶ箇所はこの 1 つになる。関数は `release_lib/approval.py` から `lib/approved_commit.py` へ移す - **前のプランの結果は、キューが渡す結果でなく状態ディレクトリから読む。** 今の `results_named` が承認ゲートの後に単独で流すときに使う経路を、唯一の経路にする。その経路は、同じディレクトリのプランの `report.md` と `work/` を読む。`plans: "earlier"` はキューが done の隣に書く一覧(`.plans.json` の `stages`)から前のステージのプランを引く - **版の参照の解決は #2164 の判定を順に呼ぶ。** 控えが無ければ `release_version.observe` → `decide_dev` か `taken` で決めて控えを書く。控えがあれば `taken` → `own_released` で確かめ、当たれば `decide_dev` で決め直す(明示の版は止める)。本番は明示の版か系列の開発版の控えの `base` を使う - **版の決め直しを確かめるのは、1 回の起動で耐久ワークフローの接頭辞を決める前の判定だけである。** 判定の中身は下の「耐久ワークフローの世代」の 2 にある。その後の解決とステップは、その起動で決めた版をそのまま使う。1 回の起動の中で旧版と新版が混ざらない @@ -382,10 +382,11 @@ sequenceDiagram **決め直しは、耐久ワークフローの回復より先に判定し、決め直した世代は新しい耐久ワークフローとして先頭から始める。** 耐久ステップの記録(`prepare`・`run_step` の出力)は状態ディレクトリの外の耐久の記録にあり、上の「版を決め直したときの退避」の 1 では消えない。プランのファイルを書き換えないため、プランの中身から作る耐久ワークフローの接頭辞(`flow.prefix_of`)も変わらない。そのため次のとおり決める。 -1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらも状態ディレクトリの控えから世代を読む。読んだ世代は `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない -2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、キューのすべてのプランへ行う。前の起動の判定は根拠にせず、その起動の実行条件で確かめる - 1. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない - 2. 「流れの決まり」の版の参照の判定(`taken` → `own_released`)を流す。当たれば「版を決め直したときの退避」の 1〜3 を行ってから接頭辞を決める +1. 世代は、解決の控えの `rows.version.history` の件数である(控えが無ければ 0)。`flow.prefix_of` は、プランの鍵・中身の鍵・開始のステップに世代を加えた `plan-<パス鍵>-<中身鍵>-<世代>-<開始>` を返す。中身鍵は、プランのファイルのバイト列でなく、プランのハッシュ(`plan_digest`)から取る。`run` の `flow.plan_prefix` とキューの `admit_step` は、どちらもプランをプランの型で読んで `plan_digest` を取り、それを `prefix_of` へ渡す(今の `key_hash(body, 8)` を使わない)。キーの並びと空白だけを変えたプランは接頭辞が変わらず、E9 の判定(I7)と耐久の記録の引き当てが同じ結論になる。世代も、2 つとも状態ディレクトリの控えから読む。読んだ世代は `prefix_of` へ渡す。決め直すと世代が 1 つ増え、接頭辞が変わるため、旧版の耐久ステップの記録は引かれない +2. 控えに版の行があるプランは、接頭辞を決める前に、決め直しの事前判定を次の順で行う。`run` は耐久の記録を開く(`durable.launch`)前、キューは耐久の記録を開く(`launch_queue`)前に、その起動で流すプランへ行う。前の起動の判定は根拠にせず、その起動の実行条件で確かめる + 1. E9 の照合(下の「流し直しの判定」)を先に行う。前の結果が完了で、ハッシュが同じか前のハッシュが無く、`--from` も無いプランは流さないため、判定の対象から外す。外したプランでは origin も PR も照会しない。完了でハッシュが違うプランも `gate` で人へ戻すため外す + 2. 実行条件を評価する(E4 と同じ関数)。飛ばすなら、版を照会せず、判定も退避もしない + 3. 「流れの決まり」の版の参照の判定(`taken` → `own_released`)を流す。当たれば「版を決め直したときの退避」の 1〜3 を行ってから接頭辞を決める 飛ばすプランは、控えの版のまま耐久ワークフローへ渡し、耐久ワークフローの中の E4 が同じ結果(完了)を返す。控えが無いプランの最初の版は、これまでどおり `resolve_head` の中で決め、世代は 0 のままである 3. 世代が変わったら、前の世代の接頭辞の途中の耐久ワークフローを、新しい世代を始める前に止める。`run` は `durable.launch` の `keep` へ新しい接頭辞を渡し、回復の前に止める。キューは接頭辞を耐久ステップ `admit_step` の中で決める。キューの再開(`launch_queue` の `keep=None`)では、その出力が再生されて旧世代の接頭辞が使われる。そのため、2 の事前判定で 1 つでも決め直したプランがあれば、`launch_queue` を `fresh` で開く。途中の耐久ワークフローは子ごと止まり、`admit_step` が世代の増えた接頭辞を計算し直す。完了したプランは、完了の記録が接頭辞で引けるため流し直されない。決め直しが無ければ、これまでどおり続きから再開する @@ -434,10 +435,10 @@ stateDiagram-v2 | 1 | 版を明示しない `new sprint`・`new close`・`loop sprint` が組むプランのファイルに、版数と記録の PR の番号が無く `refs` がある。設定の値と予算の秒は今と同じ値で入る | 組む時点に版を書く/記録の PR の番号を書く/旧い差し込み(`{release_version}`・`{queue_record}`)を書く | | 2・I1・I2 | 流す前と後でプランのファイルの sha256 が同じ。ステップに入った値ごとに `ref` 行がある | 解決した値をプランへ書き戻す/`ref` 行を書かずに流す | | 3・I4 | 記録の PR が無い・origin を読めない・前のプランの完了した結果が無い、のそれぞれでステップが流れず、`stopped` と `gaps` の `ref:<名前>` と理由が出る | 見つからない PR を `0` で埋める/読めない版を前の版で埋める | -| 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる | 照合を飛ばして流す/許した PR まで食い違いとする | +| 4・I5 | 承認したコミットの後に許していないコミットがある・承認した版と解決した版が違う、で本番のステップが流れず `gate` と両方の値。許した配布の PR だけのときは流れる。本番のエントリポイントを手動で、承認資料と違う `--version` で呼ぶと、コミットが承認どおりでも `gate` | 照合を飛ばして流す/許した PR まで食い違いとする/エントリポイントの契約の `refs` を `tip` だけにする | | 5・I6 | 同じプランを流し直すと控えの版を使う。#2164 の決め直しに当たるときだけ版が変わり、`history` と `ref` 行に前の版と理由が残る。決め直すと旧版のステップの記録が `superseded/v<旧版>/` へ移り、新版の作業場所で、世代の増えた新しい耐久ワークフローとして最初のステップから流れる。決め直した後に落ちて打ち直しても、旧版の耐久ステップの記録を引かない。キューを `--resume` で再開して決め直しが起きても、`admit_step` が世代の増えた接頭辞で流す | 流し直しのたびに次に空いている版を決める/決め直しで `history` を上書きする/旧版の済んだステップの記録を新版で再利用する/耐久ワークフローの接頭辞に世代を入れない/キューの再開で `admit_step` の旧い出力を再生する | -| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。控えに版の行があるプランも、実行条件で飛ぶ起動では、決め直しの事前判定で版を照会せず、状態ディレクトリを退避しない。版を省いた配布のプランは、worktree を版の入った `branch` と作業場所で作る | `and not start` を戻す/参照を実行条件より先に解決する/事前判定を実行条件より先に流す/上位の欄を解決せずに worktree を作る | -| 7・I7 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュ | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュを取る | +| 6・I3 | #2349 の再現の手順: 実行条件で飛ぶプランを `--from <プラン>=<ステップ>` で始めても飛ばされ、結果に飛ばした旨。実行条件は参照より先に評価され、飛ぶプランは版を照会しない。控えに版の行があるプランも、実行条件で飛ぶ起動では、決め直しの事前判定で版を照会せず、状態ディレクトリを退避しない。版を省いた配布のプランは、`run` でもキューでも、worktree を版の入った `branch` と作業場所で作る。キューで流したとき、`{ref:` を含む名前の worktree とブランチが作られない | `and not start` を戻す/参照を実行条件より先に解決する/事前判定を実行条件より先に流す/上位の欄を解決せずに worktree を作る/`worktrees_step` が上位の欄に参照を持つプランも生の値で作る | +| 7・I7 | #2349 の再現の手順: 完了したプランを含む done を `--resume` しても流れない。完了の後にプランを書き足すと `gate` と `--from` のコマンド。キーの並びだけを変えたプランは同じハッシュで、同じ耐久ワークフローの接頭辞になり流し直されない。`--resume` で流さない完了したプランでは、決め直しの事前判定が origin も PR も照会しない | ハッシュを見ずに流し直す/ファイルのバイト列でハッシュか接頭辞の中身鍵を取る/完了したプランにも事前判定を流す | | 8 | #2354 の再現の手順: `loop sprint` を `--dev` なしで起動でき、組んだスプリントのプランが版の参照を持つ。`--dev` を渡せば版の参照の `explicit` に入り、既出の版なら `stopped`・6 | `--dev` を省いたときに既定の版を入れる/明示の版をリテラルで書いて既出の判定を飛ばす | | 9 | #2164 の受け入れ条件 1〜10 と #2230 の受け入れ条件の既存のテストが通る。`supervise_lib/queue_fill.py` が無く、`queue.py` に `fill_` で始まる関数が無い | 差し込みの関数をキューに残して二重に解決する | | 10・F7 | 前の版が組んだプラン(差し込みのもの・値を書き込んだもの)と `version.json` を持つ状態ディレクトリで流れ、`ref` 行の `source` が `plan:legacy` / `store` | 移行関数を外す/旧い差し込みを文字のまま流す | From b31183dcf521b747d21b6c8977c73e4fb980f489 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:03:31 +0000 Subject: [PATCH 32/39] =?UTF-8?q?fix(factory):=20=E5=86=AA=E7=AD=89?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E9=80=81=E3=81=AE=E3=82=A2=E3=82=AB=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=88=E7=85=A7=E5=90=88=E3=83=BB=E7=AB=B6=E5=90=88?= =?UTF-8?q?=E6=99=82=E3=81=AE=E5=86=8D=E7=A2=BA=E8=AA=8D=E3=83=BB=E8=A6=81?= =?UTF-8?q?=E7=B4=A0=E3=81=94=E3=81=A8=E3=81=AE=20503=E3=83=BB=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=AE=E9=8D=B5=E3=81=AE=E6=8C=81=E3=81=A1=E8=B6=8A?= =?UTF-8?q?=E3=81=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startSprintRun と startLoop の再送で、鍵が指す実行・ループが要求のアカウントに属さなければ forbidden にする - 同じ鍵の先行の要求が途中で作り終えたときは、枠・持ち分・名前の断りより先にその結果を返す - 要素の中の ApiError でない例外もその要素の 503 にし、ほかの要素の成功の結果を残す(#2384 の決定 3) - 画面の作成は、結果が分からない失敗(503・通信の失敗)の後の同じ中身の送り直しで同じ冪等の鍵を使う Co-Authored-By: Claude Opus 5.5 --- factory/api/src/routes/elements.test.ts | 20 +++++++++++ factory/api/src/routes/elements.ts | 10 +++--- factory/api/src/routes/factory.test.ts | 27 ++++++++++++++ factory/api/src/runs/control.ts | 48 +++++++++++++++++-------- factory/api/src/runs/loops.ts | 46 +++++++++++++++++------- factory/web/src/api.test.ts | 25 ++++++++++++- factory/web/src/api.ts | 25 ++++++++++--- 7 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 factory/api/src/routes/elements.test.ts diff --git a/factory/api/src/routes/elements.test.ts b/factory/api/src/routes/elements.test.ts new file mode 100644 index 000000000..4bc2712e6 --- /dev/null +++ b/factory/api/src/routes/elements.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { ConflictError } from "../errors.ts"; +import { runElements } from "./elements.ts"; + +describe("要素ごとの実行(#2384 の決定 3)", () => { + it("保存の不調などの例外もその要素の 503 にし、ほかの要素の成功の結果を残す", async () => { + let done = 0; + const results = await runElements([1, 2, 3], async (n) => { + if (n === 2) throw new Error("ProvisionedThroughputExceeded"); + if (n === 3) throw new ConflictError("loop_busy", "busy"); + await new Promise((r) => setTimeout(r, 5)); + done++; + return n; + }); + expect(done).toBe(1); + expect(results[0]).toEqual({ ok: true, value: 1 }); + expect(results[1]).toMatchObject({ ok: false, status: 503 }); + expect(results[2]).toMatchObject({ ok: false, status: 409, error: { code: "loop_busy" } }); + }); +}); diff --git a/factory/api/src/routes/elements.ts b/factory/api/src/routes/elements.ts index eae8fcb43..c54aefaeb 100644 --- a/factory/api/src/routes/elements.ts +++ b/factory/api/src/routes/elements.ts @@ -1,6 +1,6 @@ // 要素ごとの実行(#2384 の決定 3・不変条件 I13)。要素を並べる数 10 で順にユースケースへ渡し、 // ApiError を要素の結果に包む。応答は入力と同じ順・同じ数で、失敗の要素はその要素のデータを持たない -import { ApiError } from "../errors.ts"; +import { AccountUnavailableError, ApiError } from "../errors.ts"; // 要素の読み出しを並べる数の上限(一覧の行の読み出しと同じ値) export const ELEMENT_CONCURRENCY = 10; @@ -22,14 +22,16 @@ export async function mapLimit(items: T[], limit: number, fn: (item: T, in return out; } -// ApiError でない失敗(保存の不調など)はそのまま投げ、要求全体の 503 にする +// ApiError でない失敗(保存の不調など)も、要求全体でなくその要素の 503 にする(決定 3)。1 要素の失敗で +// 全体を投げると、ほかの要素の書き込みが進んだまま成功の結果(1 度しか返せない委任の値など)が失われる。 +// 503 の本文は要求全体の失敗(handlers/api.ts の onError)と同じにする export function runElements(elements: E[], fn: (element: E) => Promise): Promise { return mapLimit(elements, ELEMENT_CONCURRENCY, async (element) => { try { return { ok: true as const, value: await fn(element) }; } catch (err) { - if (!(err instanceof ApiError)) throw err; - return { ok: false as const, status: err.status, error: err.body().error }; + const known = err instanceof ApiError ? err : new AccountUnavailableError(); + return { ok: false as const, status: known.status, error: known.body().error }; } }); } diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index 7655d639a..eea325c60 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -215,6 +215,33 @@ describe("作成の冪等の鍵(#2384 の受け入れ条件 9・I14)", () => expect(h.table.idempotency.size).toBe(1); }); + it("別のアカウントへ同じ鍵を送ると、元の実行・ループを返さずに forbidden", async () => { + const acc = await ready(); + const team = (await op("createTeam", "u-1", { items: [{ name: "チーム", seat_type: "standard", idempotency_key: idem() }] })).body.account_id; + await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] }); + await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] }); + expect((await op("startSprintRun", "u-1", { accountId: team, items: [{ repository_id: 101, issues: [1], idempotency_key: "cross" }] })).status).toBe(403); + expect((await op("startLoop", "u-1", { accountId: team, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "cross" }] })).status).toBe(403); + }); + + it("先行の同じ鍵が途中で作り終えたら、枠・名前の断りより先にその結果を返す", async () => { + const acc = await ready(); + const run = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "race" }] }); + const loop = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] }); + // 2 本目の最初の読み出しだけを、先行の要求が書く前の空として返す + const real = h.table.getIdempotent.bind(h.table); + let blind = 0; + h.table.getIdempotent = async (ref, now) => (blind-- > 0 ? null : real(ref, now)); + blind = 1; + const again = await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [2], idempotency_key: "race" }] }); + expect(again.status).toBe(200); + expect(again.body.sprint_run_id).toBe(run.body.sprint_run_id); + blind = 1; + const loopAgain = await op("startLoop", "u-1", { accountId: acc, items: [{ repository_id: 101, name: "l1", milestones: ["m"], idempotency_key: "race" }] }); + expect(loopAgain.status).toBe(200); + expect(loopAgain.body.loop_id).toBe(loop.body.loop_id); + }); + it("鍵の形が違えば要素だけを 400 にする", async () => { const acc = await ready(); expect((await op("startSprintRun", "u-1", { accountId: acc, items: [{ repository_id: 101, issues: [1], idempotency_key: "空白 あり" }] })).status).toBe(400); diff --git a/factory/api/src/runs/control.ts b/factory/api/src/runs/control.ts index c380befe6..5efedf06b 100644 --- a/factory/api/src/runs/control.ts +++ b/factory/api/src/runs/control.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import type { JobFinishedEvent, RunJobInstruction } from "@ndf-factory/contracts"; import { judgeLimits } from "../accounts/limits.ts"; import type { AccountContext } from "../accounts/model.ts"; -import { ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; +import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError, ServiceUnavailableError } from "../errors.ts"; import type { GitHubPort } from "../github/port.ts"; import type { OrganizerWrite, OrgRequest } from "../organizer/model.ts"; import { finishedKey } from "../progress/authorize.ts"; @@ -184,7 +184,6 @@ export class RunControl { // 開始。確かめの順は 課題の形(400)→ 登録(404)→ 課題(400)→ ループのマイルストーン(409)→ キー(409)→ 導入(409)→ // 使える量(409)→ 版(409)→ 課題の持ち分(409) async startSprintRun(ctx: AccountContext, input: StartInput): Promise<{ sprint_run_id: string; state: "starting" }> { - const { table, github } = this.deps; const repositoryId = input.repositoryId; if (!Number.isInteger(repositoryId) || (repositoryId as number) < 1) throw new InvalidRequestError("リポジトリの ID の形ではありません"); const issues = input.issues; @@ -200,30 +199,51 @@ export class RunControl { const idempotency: IdempotencyRef | undefined = input.idempotencyKey === undefined ? undefined : { userId: ctx.userId, operationId: "startSprintRun", key: input.idempotencyKey }; // 同じ鍵の 2 回目は 1 回目の結果を返し、何も確かめず何も書かない - const replayed = idempotency ? await table.getIdempotent(idempotency, this.iso()) : null; - if (replayed) return { sprint_run_id: replayed, state: "starting" }; - const registration = await table.getRegistration(ctx.accountId, repositoryId as number); + const replayed = idempotency ? await this.replayRun(ctx, idempotency) : null; + if (replayed) return replayed; + try { + return await this.createSprintRun(ctx, repositoryId as number, issues as number[], idempotency); + } catch (err) { + // 同じ鍵の先行の要求が途中で作り終えていれば、枠・持ち分の断りより先にその実行を返す + const won = idempotency && err instanceof ApiError ? await this.replayRun(ctx, idempotency) : null; + if (won) return won; + throw err; + } + } + + // 期限の中の同じ鍵が作った実行。別のアカウントの実行なら返さずに forbidden(鍵は利用者ごとで、アカウントをまたぐ) + private async replayRun(ctx: AccountContext, ref: IdempotencyRef): Promise<{ sprint_run_id: string; state: "starting" } | null> { + const runId = await this.deps.table.getIdempotent(ref, this.iso()); + if (!runId) return null; + const run = await this.deps.table.getRun(runId); + if (!run || run.accountId !== ctx.accountId) throw new ForbiddenError(); + return { sprint_run_id: runId, state: "starting" }; + } + + private async createSprintRun( + ctx: AccountContext, + repositoryId: number, + issues: number[], + idempotency: IdempotencyRef | undefined, + ): Promise<{ sprint_run_id: string; state: "starting" }> { + const { table, github } = this.deps; + const registration = await table.getRegistration(ctx.accountId, repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); - const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues as number[]); + const check = await github.checkOpenIssues(registration.installationId, registration.repositoryId, registration.fullName, issues); if (check.invalid.length > 0) throw new InvalidRequestError("開いている課題ではありません", "invalid_issues", { numbers: check.invalid }); // 動いているループのマイルストーンに属する課題は断る(ループの周回の選定と取り合う。決定 11) const titles = [...new Set(Object.values(check.milestones ?? {}).filter((t): t is string => typeof t === "string" && t !== ""))]; await this.refuseIssuesInLoop(ctx.accountId, registration.repositoryId, titles, check.milestones ?? {}); const created = await this.admit(ctx, registration, { - issues: issues as number[], + issues: issues, milestoneTitles: titles, milestonesOf: check.milestones ?? {}, start: "new", ...(idempotency ? { idempotency } : {}), }); - if (created === "duplicate") { - // 同じ鍵の作成が同時に走り、先に書いた方が勝った - const won = await table.getIdempotent(idempotency!, this.iso()); - if (!won) throw new ServiceUnavailableError("sprint_run_unavailable"); - return { sprint_run_id: won, state: "starting" }; - } - if (created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable"); + // duplicate は同じ鍵の作成が同時に走り、先に書いた方が勝った。勝った実行は呼び出し元が鍵から引き直す + if (created === "duplicate" || created === "loop_changed") throw new ServiceUnavailableError("sprint_run_unavailable"); return { sprint_run_id: created.sprintRunId, state: "starting" }; } diff --git a/factory/api/src/runs/loops.ts b/factory/api/src/runs/loops.ts index aa470b992..fa1cc337d 100644 --- a/factory/api/src/runs/loops.ts +++ b/factory/api/src/runs/loops.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import type { LoopLapClosedPayload, LoopStoppedPayload } from "@ndf-factory/contracts"; import type { AccountContext, AccountRecord, SeatRecord } from "../accounts/model.ts"; -import { ApiError, ConflictError, InvalidRequestError, NotFoundError } from "../errors.ts"; +import { ApiError, ConflictError, ForbiddenError, InvalidRequestError, NotFoundError } from "../errors.ts"; import type { ProgressReader } from "../progress/read.ts"; import type { LoopHooks, RunControl } from "./control.ts"; import { @@ -183,7 +183,6 @@ export class LoopControl { // F1: 始める。マイルストーンの持ち分とループの行を 1 回で書き、続けて最初の周回を起こす(枠で待つときも 201) async startLoop(ctx: AccountContext, repositoryId: number, body: Record): Promise<{ loop_id: string; state: DevLoop["state"] }> { - const { table } = this.deps; const name = body.name; if (typeof name !== "string" || !LOOP_NAME.test(name)) throw new InvalidRequestError("ループの名前は英数字と - _ で 64 字までにしてください"); const milestones = body.milestones; @@ -202,6 +201,31 @@ export class LoopControl { body.idempotency_key === undefined ? undefined : { userId: ctx.userId, operationId: "startLoop", key: parseIdempotencyKey(body.idempotency_key) }; const replayed = idempotency ? await this.replayLoop(ctx, idempotency) : null; if (replayed) return replayed; + let loop: DevLoop; + try { + loop = await this.insertLoop(ctx, repositoryId, name, milestones as string[], budget, idempotency); + } catch (err) { + // 同じ鍵の先行の要求が途中で作り終えていれば、名前・持ち分の断りより先にそのループを返す + const won = idempotency && err instanceof ApiError ? await this.replayLoop(ctx, idempotency) : null; + if (won) return won; + throw err; + } + // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52) + await this.startNextLap(ctx.accountId, loop.loopId, true); + const after = await this.deps.table.getLoop(ctx.accountId, loop.loopId); + return { loop_id: loop.loopId, state: after?.state ?? loop.state }; + } + + // ループの行とマイルストーンの持ち分を 1 回で書く。断るときは ApiError を投げる + private async insertLoop( + ctx: AccountContext, + repositoryId: number, + name: string, + milestones: string[], + budget: LoopBudget, + idempotency: IdempotencyRef | undefined, + ): Promise { + const { table } = this.deps; const registration = await table.getRegistration(ctx.accountId, repositoryId); if (!registration) throw new NotFoundError("repository_not_found", "登録したリポジトリが見つかりません"); const same = (await table.listLoops(ctx.accountId)).find((l) => l.repositoryId === repositoryId && l.name === name && !isTerminalLoop(l.state)); @@ -212,7 +236,7 @@ export class LoopControl { accountId: ctx.accountId, repositoryId, name, - milestones: milestones as string[], + milestones, budget, state: "between_laps", waitReason: null, @@ -233,11 +257,8 @@ export class LoopControl { lastRunAt: null, }; const created = await table.createLoop(loop, idempotency && claimOf(idempotency, loop.loopId, now)); - if (created === "duplicate") { - const won = await this.replayLoop(ctx, idempotency!); - if (!won) throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください"); - return won; - } + // 同じ鍵の作成が同時に走り、先に書いた方が勝った。勝ったループは呼び出し元が鍵から引き直す + if (created === "duplicate") throw new ConflictError("loop_busy", "ループの状態が変わりました。やり直してください"); if (created !== "created") { const share = await table.getMilestoneShare(ctx.accountId, repositoryId, created.conflict); const other = share ? await table.getLoop(ctx.accountId, share.loopId) : null; @@ -247,18 +268,17 @@ export class LoopControl { loop_name: other?.name ?? null, }); } - // 始めるのは利用者の操作のため、local でも最初の周回を起こす(I52) - await this.startNextLap(ctx.accountId, loop.loopId, true); - const after = await table.getLoop(ctx.accountId, loop.loopId); - return { loop_id: loop.loopId, state: after?.state ?? loop.state }; + return loop; } // 期限の中の同じ鍵が作ったループの今の状態 private async replayLoop(ctx: AccountContext, ref: IdempotencyRef): Promise<{ loop_id: string; state: DevLoop["state"] } | null> { const loopId = await this.deps.table.getIdempotent(ref, this.iso()); if (!loopId) return null; + // 鍵は利用者ごとでアカウントをまたぐ。別のアカウントのループなら返さずに forbidden const loop = await this.deps.table.getLoop(ctx.accountId, loopId); - return { loop_id: loopId, state: loop?.state ?? "between_laps" }; + if (!loop) throw new ForbiddenError(); + return { loop_id: loopId, state: loop.state }; } // 周回の開始(決定 2、I44・I52)。between_laps で今の実行が無いループだけを起こす。条件で落ちれば読み直して 3 回まで diff --git a/factory/web/src/api.test.ts b/factory/web/src/api.test.ts index b3aa988b1..69d8edf5a 100644 --- a/factory/web/src/api.test.ts +++ b/factory/web/src/api.test.ts @@ -1,7 +1,7 @@ // 画面の API クライアントと契約の対応(#2384 の受け入れ条件 1、性能の条件) import { describe, expect, it } from "vitest"; import { MAX_ITEMS, OPERATIONS } from "@ndf-factory/contracts/operations"; -import { createApi, type Api } from "./api.ts"; +import { ApiFailure, createApi, type Api } from "./api.ts"; import type { Auth } from "./auth.ts"; const API_URL = "https://api.example.test"; @@ -90,6 +90,29 @@ describe("画面の API クライアントと契約(受け入れ条件 1)", expect(keys[0]).toMatch(/^[0-9A-Za-z-]{1,64}$/); expect(keys[0]).not.toBe(keys[1]); }); + + it("結果が分からない失敗(503・通信の失敗)の後の同じ中身の送り直しは同じ鍵を使い、断りの後は変える", async () => { + const keys: string[] = []; + const replies = [503, 0, 200, 409, 200]; + const doFetch: typeof fetch = async (_input, init) => { + keys.push((JSON.parse(String(init?.body)) as { idempotency_key: string }[])[0]!.idempotency_key); + const status = replies.shift()!; + if (status === 0) throw new TypeError("Failed to fetch"); + if (status === 503) return Response.json({ error: { code: "audit_unavailable", message: "" } }, { status }); + if (status === 409) return Response.json([{ ok: false, status, error: { code: "limits_exceeded", message: "" } }]); + return Response.json([{ ok: true, value: {} }]); + }; + const api = createApi(API_URL, auth, doFetch); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(TypeError); + await api.createTeam("t", "standard"); + await expect(api.createTeam("t", "standard")).rejects.toBeInstanceOf(ApiFailure); + await api.createTeam("t", "standard"); + expect(keys[1]).toBe(keys[0]); + expect(keys[2]).toBe(keys[0]); + expect(keys[3]).not.toBe(keys[0]); + expect(keys[4]).not.toBe(keys[3]); + }); }); describe("要求の回数(非機能の条件「性能・拡張性」)", () => { diff --git a/factory/web/src/api.ts b/factory/web/src/api.ts index 873d61929..f3406809d 100644 --- a/factory/web/src/api.ts +++ b/factory/web/src/api.ts @@ -341,6 +341,23 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. // 要素 1 個を送り、その結果の値を返す。失敗の要素は ApiFailure にする const one = async (id: OperationId, accountId: string | null, item: Record) => valueOf((await request[]>(id, accountId, { items: [item] }))[0]); + // 作成(新しい識別子を作る操作)。冪等の鍵は送る中身ごとに持ち、結果が分からない失敗(503・通信の失敗)の後に + // 同じ中身を送り直すときは同じ鍵を使う(保存の後に記録の追記が落ちた 503 で、作り直しを 2 重にしない。#2384 の決定 4)。 + // 成功か、断りの結果(503 以外の失敗)が届いたら鍵を捨て、次の送信は新しい作成にする + const pendingKeys = new Map(); + const create = async (id: OperationId, accountId: string | null, item: Record) => { + const slot = JSON.stringify([id, accountId, item]); + const idempotency_key = pendingKeys.get(slot) ?? newKey(); + pendingKeys.set(slot, idempotency_key); + try { + const value = await one(id, accountId, { ...item, idempotency_key }); + pendingKeys.delete(slot); + return value; + } catch (err) { + if (err instanceof ApiFailure && err.status !== 503) pendingKeys.delete(slot); + throw err; + } + }; // 鍵の query で指す取得と削除。要素の結果を入力と同じ順に返す const select = async (id: OperationId, accountId: string | null, key: string, values: (string | number)[], query: Record = {}) => { const out: ElementResult[] = []; @@ -354,7 +371,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. return { me: async () => (await list("getMe", null))[0]!, account: async (id) => (await values("getAccount", null, "account_id", [id]))[0]!, - createTeam: (name, seat_type) => one("createTeam", null, { name, seat_type, idempotency_key: newKey() }), + createTeam: (name, seat_type) => create("createTeam", null, { name, seat_type }), setExecutionMode: (id, execution_mode) => one("setExecutionMode", null, { account_id: id, execution_mode }), addSeat: (id, user_id, seat_type) => one("addSeat", id, { user_id, seat_type }), changeSeat: (id, user_id, seat_type) => one("changeSeat", id, { user_id, seat_type }), @@ -375,7 +392,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. valueOf((await request[]>("deleteModelCredential", id))[0]); }, issueCandidates: (id, repository_id, exclude) => one("postIssueCandidates", id, { repository_id, exclude }), - startSprintRun: (id, repository_id, issues) => one("startSprintRun", id, { repository_id, issues, idempotency_key: newKey() }), + startSprintRun: (id, repository_id, issues) => create("startSprintRun", id, { repository_id, issues }), sprintRuns: async (id, repositoryId) => ({ sprint_runs: await list("listSprintRuns", id, repositoryId === undefined ? {} : { repository_id: repositoryId }), }), @@ -386,7 +403,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. cancelSprintRun: (id, sprint_run_id) => one("cancelSprintRun", id, { sprint_run_id }), updateRepository: (id, repository_id, max_concurrent_runs) => one("updateRepository", id, { repository_id, max_concurrent_runs }), loops: async (id, repositoryId) => ({ loops: await list("listLoops", id, { repository_id: repositoryId }) }), - startLoop: (id, repository_id, input) => one("startLoop", id, { repository_id, ...input, idempotency_key: newKey() }), + startLoop: (id, repository_id, input) => create("startLoop", id, { repository_id, ...input }), // 要素の数によらず 1 回の要求で読む。無いループ(loop_not_found)の要素は返さない loopDetails: async (id, loopIds) => { const results = await select("getLoop", id, "loop_id", loopIds); @@ -397,7 +414,7 @@ export function createApi(apiUrl: string, auth: Auth, doFetch: typeof fetch = (. requestNextLap: (id, loop_id) => one("startNextLap", id, { loop_id }), answerLoop: (id, loop_id, issue, decision, pr) => one("answerLoopPending", id, { loop_id, issue, decision, ...(pr === undefined ? {} : { pr }) }), delegations: () => list("listDelegations", null), - createDelegation: (input) => one("createDelegation", null, { ...input, idempotency_key: newKey() }), + createDelegation: (input) => create("createDelegation", null, { ...input }), revokeDelegation: async (credentialId) => (await values("revokeDelegation", null, "credential_id", [credentialId]))[0]!, }; } From 8c5982fe95caca028e6a2698391b33ba4b8ad33a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:06:37 +0000 Subject: [PATCH 33/39] =?UTF-8?q?Docs:=20#2474=20=E3=81=AE=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=81=AE=E6=B5=81=E3=82=8C=E3=81=A7=E3=80=81=E3=82=BB?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E7=B5=82=E3=81=88?= =?UTF-8?q?=E3=82=8B=E3=81=A0=E3=81=91=E3=81=AE=E4=BE=9D=E9=A0=BC=E3=82=92?= =?UTF-8?q?=E9=80=81=E4=BF=A1=E5=85=83=E3=81=AB=E3=82=88=E3=82=89=E3=81=9A?= =?UTF-8?q?=E5=BC=95=E7=B6=99=E3=81=8E=E3=81=A7=E7=B5=82=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 送信元の確認を、ループを止めることを求める文だけに掛ける。要求の E3・受け入れ条件 3 と設計の E3・F2 に揃える。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2474-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/issues/issue-2474-design.md b/issues/issue-2474-design.md index 5a9f7e45b..206748ffe 100644 --- a/issues/issue-2474-design.md +++ b/issues/issue-2474-design.md @@ -121,13 +121,15 @@ conductor が止めることを求める文を受けたときの判断を、次 ```mermaid flowchart TD - A[止める・終えることを
求める文を受けた] --> B{人が自分のセッションへ
送った入力か} - B -->|いいえ| C[止める指示を置かない
人に確かめる] - B -->|はい| D{ループを止めることを
求めているか} - D -->|はい| E[loop stop を打つ] + A[止める・終えることを
求める文を受けた] --> D{ループを止めることを
求めているか} D -->|セッションを
終えるだけ| F[引継ぎ文書を書いて終える
loop stop を打たない] + D -->|はい| B{人が自分のセッションへ
送った入力か} + B -->|はい| E[loop stop を打つ] + B -->|いいえ| C[止める指示を置かない
人に確かめる] ``` +セッションを終えるだけの依頼は、送信元によらず引継ぎ文書を書いて終える(E3・F2)。送信元を確かめるのは、ループを止めることを求める文だけである。 + 人が送っていない文から止める指示をすでに置いていたと気づいたときは、ファイルを消して引継ぎ文書に書く(F3)。 図は `loop.md` の新しい小見出しが conductor へ示す判断である。図に含めない要素: 確定仕様(文書)、変えない開発ループのコード、 From 644ce77f7e499f369cd27fd6955bd494817516ec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:13:13 +0000 Subject: [PATCH 34/39] =?UTF-8?q?fix(factory):=20=E5=A7=94=E4=BB=BB?= =?UTF-8?q?=E3=81=AE=E9=80=81=E3=82=8A=E7=9B=B4=E3=81=97=E3=81=A7=E5=80=A4?= =?UTF-8?q?=E3=81=8C=E8=BF=94=E3=82=89=E3=81=AA=E3=81=84=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=81=AB=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=8B=EF=BC=88PR=20#251?= =?UTF-8?q?8=20round=202=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結果が分からない失敗の後の再送は同じ冪等の鍵で前の発行を credential: null で返す。 画面が黙って受けると使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる。 Co-Authored-By: Claude Opus 5.5 --- factory/web/src/i18n/ja.json | 1 + factory/web/src/pages/DelegationsPage.tsx | 3 +++ factory/web/src/pages/delegations.test.tsx | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/factory/web/src/i18n/ja.json b/factory/web/src/i18n/ja.json index 566cfd820..a28c0907c 100644 --- a/factory/web/src/i18n/ja.json +++ b/factory/web/src/i18n/ja.json @@ -408,6 +408,7 @@ "value": "資格情報の値", "registerExample": "MCP のクライアントへの登録の例", "close": "閉じる", + "valueUnavailable": "値はもう表示できません。この資格情報を取り消して発行し直してください", "list": "発行した資格情報", "empty": "まだ委任していません", "createdAt": "発行", diff --git a/factory/web/src/pages/DelegationsPage.tsx b/factory/web/src/pages/DelegationsPage.tsx index 7d22925cc..64e6757a8 100644 --- a/factory/web/src/pages/DelegationsPage.tsx +++ b/factory/web/src/pages/DelegationsPage.tsx @@ -63,6 +63,9 @@ export function DelegationsPage() { const { delegation, credential } = await api.createDelegation({ client_name: name, scope, expires_in_seconds: expires }); setIssued(credential); setName(""); + // 結果が分からない失敗の後の送り直しは、同じ鍵で前の発行を返し、値を返さない(値は発行の 1 度だけ)。 + // 黙って受けると、使えない有効な資格情報が残るため、取り消して発行し直すよう知らせる + if (credential === null) setMessage(t("delegations.valueUnavailable")); setRows((prev) => [delegation, ...(prev ?? []).filter((d) => d.credential_id !== delegation.credential_id)]); } catch (err) { setMessage(onFailure(err)); diff --git a/factory/web/src/pages/delegations.test.tsx b/factory/web/src/pages/delegations.test.tsx index 52b4b043c..5087a2226 100644 --- a/factory/web/src/pages/delegations.test.tsx +++ b/factory/web/src/pages/delegations.test.tsx @@ -10,10 +10,10 @@ afterEach(cleanup); const tokyo: LocaleSettings = { language: "ja", locale: "ja-JP", timeZone: "Asia/Tokyo" }; -function show(search = "") { +function show(search = "", services = createDummyServices(search)) { render( - + , ); } @@ -35,6 +35,17 @@ describe("エージェントへの委任(#2384)", () => { expect(await within(rows[0]!).findByText("取り消し済み")).toBeTruthy(); }); + it("送り直しで前の発行が値なしで返ったら、値を出せないことと取り消して発行し直すことを知らせる", async () => { + const services = createDummyServices(""); + const create = services.api.createDelegation; + services.api.createDelegation = async (input) => ({ ...(await create(input)), credential: null }); + show("", services); + await userEvent.type(await screen.findByLabelText("クライアントの名前"), "手元の Claude Code"); + await userEvent.click(screen.getByRole("button", { name: "発行する" })); + expect(await screen.findByText("値はもう表示できません。この資格情報を取り消して発行し直してください")).toBeTruthy(); + expect(screen.queryByText(/^ndfd_/)).toBeNull(); + }); + it("名前が無ければ発行しない", async () => { show(); await userEvent.click(await screen.findByRole("button", { name: "発行する" })); From 737835031f9f41a992cb11a86d346b42e0dc7ba5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:35:02 +0000 Subject: [PATCH 35/39] Release: ndf v10.27.19-dev.1 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 12 ++++++++++++ README.md | 4 ++-- docs/versioning-and-distribution.md | 4 ++-- plugins/ndf/.claude-plugin/plugin.json | 4 ++-- plugins/ndf/.codex-plugin/plugin.json | 4 ++-- plugins/ndf/README.md | 6 +++--- 7 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc996833c..c43f586ee 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.27.18): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.27.19-dev.1): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/CHANGELOG.md b/CHANGELOG.md index e73334e89..bcf79892c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.19] - 2026-11-05 + +- 委任の資格情報を画面から発行・取り消しできる。委任の一覧は専用のページで確認できる(#2512)(#2518) +- 人だけが確定できる操作は、委任の資格情報経由で要求すると断られる(#2512)(#2518) +- 利用者向けの操作は、1 回ごとに操作の記録へ追記される(#2512)(#2518) +- MCP の受け口から呼んだツールは、画面と同じアプリの経路で処理される(#2512)(#2518) +- API と画面の間の受け渡しは、要素の配列の形で行う(#2512)(#2518) + +### 移行の手順 + +- Factory: 画面と API は同じ版で同時に切り替わり、旧い形の経路は残らない。API を直接呼んでいる場合は、要素の配列で受け渡す新しい形へ呼び出し側を書き換える(#2512)(#2518) + ## [ndf 10.27.18] - 2026-11-05 - Docs: 並列のレーンの画面の見方と、入力欄のサジェストを判断に使わないことを書く(#2502) diff --git a/README.md b/README.md index ce730a17e..8b10f9eb5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.18 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.19-dev.1 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 4.0.0 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.18** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.19-dev.1** に入る 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/versioning-and-distribution.md b/docs/versioning-and-distribution.md index bb1f17aa0..5b5e194da 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.18` | 利用者が常用してよい | +| 正式版 | `10.27.19` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.18` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.19` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index ec3b6ab16..e0c2ad24d 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.18", - "description": "Claude Code plugin (v10.27.18): 8 specialized agents (plus 2 supervisor agents and 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.", + "version": "10.27.19-dev.1", + "description": "Claude Code plugin (v10.27.19-dev.1): 8 specialized agents (plus 2 supervisor agents and 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.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 6d706bf3c..7a2198742 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.18", - "description": "Codex plugin (v10.27.18): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.27.19-dev.1", + "description": "Codex plugin (v10.27.19-dev.1): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 8849dee8c..55f4a0ae9 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -350,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.18/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.19-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -372,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.18/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.19-dev.1/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.27.18 +# => ndf@ai-plugins installed, enabled 10.27.19-dev.1 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 From 6864c6bd25067d68b724c850f8b86e67339025cd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:48:10 +0000 Subject: [PATCH 36/39] =?UTF-8?q?Docs:=20#2199=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AE=E5=88=A4=E5=AE=9A=E8=A1=A8=E3=82=92=202026-1?= =?UTF-8?q?1-05=20=E3=81=AE=20develop=EF=BC=8886436dace=EF=BC=89=E3=81=A7?= =?UTF-8?q?=E7=A2=BA=E3=81=8B=E3=82=81=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireEnv の写しが 5 か所・ログインの確認が 12 画面に増えたことを反映し、 ログインの確認を画面の枠の経路 1 か所へ寄せる形、createTeam の冪等の鍵の行を 保つ transact の並び、ensureUser・createTeam も含めた writeOr の形を決める。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2199-design.md | 155 ++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 69 deletions(-) diff --git a/issues/issue-2199-design.md b/issues/issue-2199-design.md index fbd7a17e6..a757bad75 100644 --- a/issues/issue-2199-design.md +++ b/issues/issue-2199-design.md @@ -2,7 +2,7 @@ ## 目的 -- **何が壊れているか**: #2170 の cross-refactoring(PR #2192)が `factory/` のリファクタリングの提案 20 件(R01〜R20)を時間(`budget`)で見送り、どの課題にも残っていない。その後に重複が広がり、2026-10-31 の `develop`(ee32e92f)では `requireEnv` が 4 か所、ログインの確認が 6 画面にある +- **何が壊れているか**: #2170 の cross-refactoring(PR #2192)が `factory/` のリファクタリングの提案 20 件(R01〜R20)を時間(`budget`)で見送り、どの課題にも残っていない。その後に重複が広がり、2026-11-05 の `develop`(86436dace)では `requireEnv` が 5 か所、ログインの確認が 12 画面、失敗を画面の文へ変える `onFailure(err)` が 23 行にある - **誰が困るか**: 同じ `factory/api/`・`factory/web/` に足していく子課題(#2171・#2172・#2173・#2174・#2189)の実装者と、その cross-refactoring。重複を写して広げ、同じ提案を毎回出し直しては時間で見送る - **直すと何が成り立つか**: 20 件のそれぞれが「適用した」か「採らない(理由つき)」に決まり、同じ役割の関数の定義が `factory/` の中で 1 か所になる。HTTP の応答・OpenAPI の契約・DynamoDB の行の形・画面の表示は変わらない @@ -10,18 +10,19 @@ - **働く範囲**: このリポジトリの `factory/api/src`・`factory/web/src` と `scripts/tests/test_check_markdown_links.py` だけ。配布物(`plugins/`)には届かない - **プロジェクトごとに違うもの**: 無し(`factory/` の内部の関数の置き場と引数の形だけを変える) -- **当たるモード**: `standard`。要求と受け入れ条件があり、対象には既存のテスト(`factory/` の vitest 19 ファイル・pytest 46 件)がある +- **当たるモード**: `standard`。要求と受け入れ条件があり、対象には既存のテスト(`factory/api/src`・`factory/web/src` の vitest 37 ファイル・pytest 46 件)がある ## あるべき姿の根拠 | 根拠 | 種類 | 何を示すか | | --- | --- | --- | | PR #2192 のリファクタリング計画のコメント「見送った提案」の 20 行(理由はすべて `budget`)と、提案の結果ファイル(`/tmp/ndf-worktrees/devbasex--ai-plugins/rf2192/work/.cross_refactoring/{claude,codex,kiro}-propose-rf2192-result.json`)の `rationale` と `plan` | 実測(記録の読み取り) | 各件の兆候と、提案した手順。この設計の判定表の出所 | -| 2026-10-31 の `develop`(ee32e92f)で `grep -rn "function requireEnv" factory/api/src` が 4 行(`handlers/api.ts`・`handlers/github-oidc.ts`・`handlers/ingest.ts`・`runs/deps.ts`) | 実測 | PR #2192 の後に写しが 2 つ増えた(要求の前提 1) | -| 同じ日の `grep -rn 'Navigate to="/login"' factory/web/src/pages` が 6 行、`grep -rn "onFailure(err)" factory/web/src` が 13 行 | 実測 | 画面の重複も PR #2192 の後に足された 3 画面(`RepositoriesPage`・`ModelCredentialPage`・`NewSprintPage`)へ写っている | -| 同じ日の `factory/api/src/github-oidc/app.ts` に `clientCredentials`・`exchangeGithubCode`・`issueIdToken` があり、POST /token の本体は 24 行 | 実測 | R07 の兆候は PR #2192 の I-001 で消えている(要求の前提 2) | -| `factory/api/src/index.ts` が `./handlers/api.ts` と `./handlers/ingest.ts` を import し、どちらも読み込みの時点で `export const handler = handle(...)` を組み立てる | 実測(コードの読み取り) | 公開の入口が Lambda の入口を読む向き(R02)。`runs/deps.ts` も `handlers/ingest.ts` を読む | -| 着手前の `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が `46 passed` | 実測(2026-10-31) | 受け入れ条件 5 の「変更の前の件数」 | +| 2026-11-05 の `develop`(86436dace)で `grep -rn "function requireEnv" factory/api/src` が 5 行(`handlers/api.ts`・`handlers/github-oidc.ts`・`handlers/ingest.ts`・`handlers/issue-requests.ts`・`runs/deps.ts`) | 実測 | PR #2192 の後に写しが 3 つ増えた(要求の前提 1) | +| 同じ日の `grep -rn 'Navigate to="/login"' factory/web/src/pages` が 12 行、`factory/web/src` の `onFailure(err)` が 13 ファイルで 23 行 | 実測 | 画面の重複が PR #2192 の後に足された画面(スプリント・ループ・委任・バックログなど)へ写っている | +| 同じ日の `factory/web/src/App.tsx` が、ログインの後の画面をすべて 1 つの枠(`AppShell` の layout route)の下に置く | 実測(コードの読み取り) | ログインの確認を 1 か所に置ける場所が既にある(R14) | +| 同じ日の `factory/api/src/github-oidc/app.ts` に `clientCredentials`・`exchangeGithubCode`・`issueIdToken` があり、POST /token の本体は 21 行 | 実測 | R07 の兆候は PR #2192 の I-001 で消えている(要求の前提 2) | +| `factory/api/src/index.ts` が `./handlers/api.ts`(`depsFromEnv`・`createApiApp`)と `./handlers/ingest.ts`(`progressStoreFromEnv`)を読み、どちらも読み込みの時点で `export const handler = ...` を組み立てる。`runs/deps.ts` も `handlers/ingest.ts` を読む | 実測(コードの読み取り) | 公開の入口が Lambda の入口を読む向き(R02) | +| 着手前の `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が `46 passed`(2026-11-05) | 実測 | 受け入れ条件 5 の「変更の前の件数」 | 要求と受け入れ条件は #2199 の本文にある(コピーは [issue-2199-requirements.md](issue-2199-requirements.md))。この文書は「どう作るか」だけを扱う。 @@ -43,7 +44,7 @@ | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | | 利用者 | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | 利用者(`UserRecord`) | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | -| アカウント | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | アカウント(`AccountRecord`) | 席(`SeatRecord`) | プラン・実行の形態・席の種類・履歴の行(`HistoryRecord`) | +| アカウント | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | アカウント(`AccountRecord`) | 席(`SeatRecord`) | プラン・実行の形態・席の種類・履歴の行(`HistoryRecord`)・作成の冪等の鍵の行 | 利用者とアカウントは別の集約である(確定仕様 `docs/specifications/ndf-factory-accounts-and-plans.md` の「集約」)。2 つを 1 つのトランザクションで書くのは初めてのログインで両方を作るときだけで、それ以外の利用者の読み書きはアカウントの操作に含めない。保存の契約(`AccountStore`)が両方の集約の保存を受け持つのは今のままで、この変更は契約の置き場(`accounts/port.ts`)だけを変える。 @@ -54,11 +55,11 @@ | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | | I1 | — | HTTP の応答(状態コード・本文・エラーの文言)と画面の表示が変更の前と同じ | その件を戻し、表の扱いを「採らない(適用すると振る舞いが変わる)」にする | -| I2 | アカウント | DynamoDB へ送る `TransactItems` の項目の並び・キー・属性・条件式・プレースホルダの名前(`:plan`・`:mode` など)が変更の前と同じ | その件を戻す(C3 に当たる変更を入れない) | +| I2 | アカウント | DynamoDB へ送る `TransactItems` の項目の並び(業務の項目 → 履歴 → 作成の冪等の鍵の行)・キー・属性・条件式・プレースホルダの名前(`:plan`・`:mode` など)が変更の前と同じ | その件を戻す(C3 に当たる変更を入れない) | | I3 | アカウント | どの書き込みも履歴の行を同じトランザクションで足す(既存の I20)。条件の失敗は `ConditionFailedError`、それ以外の失敗は今と同じ型で呼ぶ側へ届く | その件を戻す | | I4 | — | 認証(`auth/verify.ts`・`github-oidc/`)と認可(`auth/resolve.ts`)の判定が変更の前と同じ | その件を戻す(C2 に当たる変更を入れない) | | I5 | — | 「適用した」件の同じ役割の関数・定数の定義が `factory/` の中で 1 か所にある | 残った写しを寄せるまで実装の Pull Request を出さない | -| I6 | — | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。テストの補助の `testing/app.ts` は除かない)が `handlers/` を import しない | import を移した先へ替えるまで出さない | +| I6 | — | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。テストの補助の `testing/app.ts` は除かない)が `handlers/` を import しない。`handlers/` の中のファイルどうしも互いを import しない | import を移した先へ替えるまで出さない | ### ドメインイベント @@ -78,8 +79,8 @@ | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 見送った提案 | cross-refactoring のリファクタリング計画の「見送った提案」の表の 1 行。ここでは PR #2192 の R01〜R20 | 変えない(`ndf-cross-refactoring` の既存の語のまま) | -| 同じ役割の写し | R01〜R20 の対象と同じ処理・同じ判断を、別の場所に書き写したもの。要求の S3 が対象に含める | 追加(`ndf-factory`) | -| 保存の契約 | `AccountStore` の interface と、その引数・失敗の型(`AccountPatch`・`AccountUpdate`・`ConditionFailedError`)。DynamoDB とメモリの 2 つの実装が満たす | 追加(`ndf-factory`) | +| 同じ役割の写し | R01〜R20 の対象と同じ処理・同じ判断を、別の場所に書き写したもの。要求の S3 が対象に含める | 登録済み(`ndf-factory`) | +| 保存の契約 | `AccountStore` の interface と、その引数・失敗の型(`AccountPatch`・`AccountUpdate`・`ConditionFailedError`)。DynamoDB とメモリの 2 つの実装が満たす | 登録済み(`ndf-factory`) | ## 機能一覧 @@ -88,35 +89,35 @@ | # | 機能 | 誰が使うか | | --- | --- | --- | | F1 | 20 件の扱い(適用した・採らない)と写しの扱いを 1 つの表で読む | 承認ゲート 2 で見る人 | -| F2 | 環境変数の読み取り・API の入口の組み立て・保存の契約・再試行を、1 か所の定義から使う | 子課題(#2171・#2172・#2173・#2174・#2189)の実装者 | +| F2 | 環境変数の読み取り・API の入口の組み立て・保存の契約・再試行・ログインの確認・失敗の表示を、1 か所の定義から使う | 子課題(#2171・#2172・#2173・#2174・#2189)の実装者 | | F3 | 後の cross-refactoring が R01〜R20 と同じ提案を出さない | cross-refactoring の提案の担当 | ## 20 件の判定表 -**この表が実装の Pull Request の本文の表(受け入れ条件 1)の下書きになる。** 「今の状態」は 2026-10-31 の `develop`(ee32e92f)で確かめた。実装の開始時に `develop` が進んでいれば、E1 として確かめ直す。 +**この表が実装の Pull Request の本文の表(受け入れ条件 1)の下書きになる。** 「今の状態」は 2026-11-05 の `develop`(86436dace)で確かめた。実装の開始時に `develop` が進んでいれば、E1 として確かめ直す。 | # | 対象 | 今の状態 | 扱い | 直し方(1 か所にまとめる場所) | 同じ役割の写し(S3) | | --- | --- | --- | --- | --- | --- | -| R01 | `routes/teams.ts#registerTeams` | 席の種類の検証と文言が `teams.ts` に直書き、`accounts.ts` に定数 | 適用する | `routes/body.ts` の `readSeatType(value)`。`SEAT_TYPE_MESSAGE` も移す | `routes/accounts.ts` の 2 か所 | -| R02 | `index.ts#depsFromEnv` | `index.ts` が `handlers/api.ts`・`handlers/ingest.ts` を読む | 適用する | 決定 1 | `runs/deps.ts` → `handlers/ingest.ts` | +| R01 | `routes/teams.ts#registerTeams` | 席の種類の検証と文言が `teams.ts` に直書き、`accounts.ts` に定数 `SEAT_TYPE_MESSAGE` | 適用する | `accounts/service.ts` の `validateSeatType(value)`(既存の `validateTeamName` の隣)。文言の定数も移す | `routes/accounts.ts` の `addSeat`・`changeSeat` | +| R02 | `index.ts#depsFromEnv` | `index.ts` が `handlers/api.ts`・`handlers/ingest.ts` を読み、`createApiApp` も `handlers/api.ts` から再 export する | 適用する | 決定 1 | `runs/deps.ts` → `handlers/ingest.ts`、`handlers/issue-requests.ts` → `handlers/ingest.ts` | | R03 | `github-oidc/app.ts#githubJson` | `/token` と `/userinfo` が `/user` の取得と ID の検証を 2 回書く | 適用する | `createGithubOidcApp` の中の `githubUser(accessToken)`(取れなければ `null`) | 無し | -| R04 | `accounts/store.ts#DynamoAccountStore.transact` | 6 つの書き込みが履歴の行の展開を末尾に書く | 適用する | 決定 3(R09 と同じ直し方) | 無し | -| R05 | `web/pages/TeamPage.tsx#TeamPage` | 席の種類の `